feat(edit): add guests from the contact picker
All checks were successful
Translations / check (pull_request) Successful in 28s
CI / ci (pull_request) Successful in 9m19s

Close the name-on-manual-add gap. The add-guest row gains a "from
contacts" button that launches the system contact picker (ACTION_PICK on
ContactsContract Email URI); the picked email row is queried for
Email.ADDRESS + DISPLAY_NAME, so a chosen guest gets both email and name
in one tap. No READ_CONTACTS — the result Intent grants temporary read
access to just that row, so Calendula stays no-permission/no-network. The
inline email field remains the quick email-only path. Mark the roadmap
item shipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 23:50:23 +02:00
parent ac17343747
commit a4e0ec3fde
4 changed files with 79 additions and 14 deletions

View File

@@ -490,15 +490,15 @@ detail screen (since v0.6) but the form can't write them. Make guests editable.
required/optional type updates; organizer/resource/no-email rows untouched). required/optional type updates; organizer/resource/no-email rows untouched).
Reminders restyled to the same grouped-list pattern in passing. Needs only the Reminders restyled to the same grouped-list pattern in passing. Needs only the
existing `WRITE_CALENDAR` — no new permission. existing `WRITE_CALENDAR` — no new permission.
- **Name on manual add — solved next via a contact picker.** The inline field is - **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
email-only (the quick path); names otherwise come from sync. The planned fix is field is email-only (the quick path); the add row also has a **"from contacts"
a **"from contacts" entry**: `ACTION_PICK` on button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI`
`ContactsContract.CommonDataKinds.Email.CONTENT_URI` → the system Contacts app the system Contacts app returns a URI to the picked email row, queried for
returns a URI to the picked email row, queried for `Email.ADDRESS` **and** the `Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
contact `DISPLAY_NAME`, so a picked guest gets both email and name directly. The email and name in one tap. The result Intent grants temporary read access, so
result Intent grants temporary read access, so **no `READ_CONTACTS` permission** **no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
— one-shot and user-driven, same no-permission mechanism as the location address mechanism as the location address picker above. Self/organizer rows stay
picker above. Self/organizer rows stay non-editable. non-editable.
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable - **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
calendars.** Calendula has no INTERNET and never sends an invitation itself; it calendars.** Calendula has no INTERNET and never sends an invitation itself; it
only writes `Attendees` rows. Whether a guest is notified is decided downstream: only writes `Attendees` rows. Whether a guest is notified is decided downstream:

View File

@@ -1,7 +1,12 @@
package de.jeanlucmakiola.calendula.ui.edit package de.jeanlucmakiola.calendula.ui.edit
import android.Manifest import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.util.Patterns import android.util.Patterns
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -38,6 +43,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
@@ -439,6 +445,22 @@ private fun EventEditContent(
var showColorPicker by rememberSaveable { mutableStateOf(false) } var showColorPicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) }
// Pick a guest from contacts: a one-shot system picker returning the chosen
// email row (email + display name). No READ_CONTACTS — the result Intent
// grants temporary read access, so Calendula stays no-network/no-permission.
val context = LocalContext.current
val pickContact = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
readContactEmail(context, uri)?.let { (email, name) ->
viewModel.addAttendee(email, name)
}
}
}
}
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
// The accent ties the form to the detail screen's design language: the // The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour. // bar under the title takes the target calendar's colour.
@@ -797,6 +819,16 @@ private fun EventEditContent(
AddGuestInlineCard( AddGuestInlineCard(
position = positionOf(guests.size, rows), position = positionOf(guests.size, rows),
onAdd = { viewModel.addAttendee(it) }, onAdd = { viewModel.addAttendee(it) },
onPickContact = {
runCatching {
pickContact.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
),
)
}
},
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -1457,13 +1489,17 @@ private fun AddActionCard(label: String, position: Position, onClick: () -> Unit
} }
/** /**
* The trailing card of the guests list: an inline email field. Type an address * The trailing card of the guests list: an inline email field plus a "from
* and press Done (or tap the add button) to commit it as a guest; the field * contacts" button. Type an address and press Done (or tap +) to commit a guest;
* clears, ready for the next. Matches the form's inline-field input style — no * or pick from contacts to fill email + name at once. The field clears after,
* dialog. * ready for the next. Matches the form's inline-field input style — no dialog.
*/ */
@Composable @Composable
private fun AddGuestInlineCard(position: Position, onAdd: (String) -> Unit) { private fun AddGuestInlineCard(
position: Position,
onAdd: (String) -> Unit,
onPickContact: () -> Unit,
) {
var text by rememberSaveable { mutableStateOf("") } var text by rememberSaveable { mutableStateOf("") }
val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() } val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() }
fun commit() { fun commit() {
@@ -1511,10 +1547,37 @@ private fun AddGuestInlineCard(position: Position, onAdd: (String) -> Unit) {
) )
} }
} }
IconButton(onClick = onPickContact, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(R.string.event_edit_add_guest_from_contacts),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
} }
} }
} }
/**
* Read the email + display name from a contact-pick result URI. No
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
*/
private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>? =
context.contentResolver.query(
uri,
arrayOf(
ContactsContract.CommonDataKinds.Email.ADDRESS,
ContactsContract.Contacts.DISPLAY_NAME,
),
null, null, null,
)?.use { cursor ->
if (!cursor.moveToFirst()) return@use null
val email = cursor.getString(0).orEmpty().trim()
val name = cursor.getString(1).orEmpty().trim()
email.takeIf { it.isNotEmpty() }?.let { it to name }
}
private fun fieldLabel(field: EventFormField): Int = when (field) { private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location EventFormField.Location -> R.string.event_detail_location

View File

@@ -80,6 +80,7 @@
<string name="event_edit_attendees">Gäste</string> <string name="event_edit_attendees">Gäste</string>
<string name="event_edit_add_guest">Gast hinzufügen</string> <string name="event_edit_add_guest">Gast hinzufügen</string>
<string name="event_edit_add_guest_hint">Gast per E-Mail hinzufügen…</string> <string name="event_edit_add_guest_hint">Gast per E-Mail hinzufügen…</string>
<string name="event_edit_add_guest_from_contacts">Aus Kontakten hinzufügen</string>
<string name="event_edit_remove_guest">Gast entfernen</string> <string name="event_edit_remove_guest">Gast entfernen</string>
<string name="event_edit_attendee_required">Erforderlich</string> <string name="event_edit_attendee_required">Erforderlich</string>
<string name="event_edit_attendee_optional">Optional</string> <string name="event_edit_attendee_optional">Optional</string>

View File

@@ -81,6 +81,7 @@
<string name="event_edit_attendees">Guests</string> <string name="event_edit_attendees">Guests</string>
<string name="event_edit_add_guest">Add guest</string> <string name="event_edit_add_guest">Add guest</string>
<string name="event_edit_add_guest_hint">Add a guest by email…</string> <string name="event_edit_add_guest_hint">Add a guest by email…</string>
<string name="event_edit_add_guest_from_contacts">Add from contacts</string>
<string name="event_edit_remove_guest">Remove guest</string> <string name="event_edit_remove_guest">Remove guest</string>
<string name="event_edit_attendee_required">Required</string> <string name="event_edit_attendee_required">Required</string>
<string name="event_edit_attendee_optional">Optional</string> <string name="event_edit_attendee_optional">Optional</string>