From b0f34ff18a019851104ff08affd5d4fc27461a79 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:42:37 +0200 Subject: [PATCH] feat(edit): make event attendees editable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attendees were read-only (shown on the detail screen since v0.6) but the form couldn't write them — the last read-only gap in the event model. Add a Guests section to the create/edit form: - Inline grouped list: each guest is a tonal card (avatar, name/email, a tappable Required/Optional role chip, remove); the trailing card is an inline email field — type an address, press Done, it commits. No dialog, matching the app's inline-field input idiom. Manual adds are email-only (names come from sync); the InlineTextField gains IME-action support. - Reminders restyled to the same grouped-list pattern (shared GroupedItemCard / AddActionCard), replacing the single-card blob. Persistence (CalendarDataSource): new guests are written as plain RELATIONSHIP_ATTENDEE / STATUS_INVITED rows — no fabricated organizer. On edit, a dirty-checked reconcileAttendees diffs by email: drops removed guests, inserts new ones, updates only the required/optional flag on kept rows (preserving response status). Organizer, resources and no-email rows are never touched. toEditForm carries only editable guests, so attendees now ride in the edit snapshot and an external guest change trips the conflict check. Per the settled invitation decision: Calendula has no INTERNET and never sends an invitation — it only writes the rows; the backend decides delivery. The section shows honest, calendar-aware copy ("your account may email guests when it syncs" on synced calendars, "no one is notified" on local). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarDataSource.kt | 141 ++++++++ .../calendula/domain/EventForm.kt | 48 ++- .../calendula/ui/common/InlineTextField.kt | 9 + .../calendula/ui/edit/EventEditScreen.kt | 325 +++++++++++++++--- .../calendula/ui/edit/EventEditViewModel.kt | 28 ++ .../calendula/ui/settings/SettingsScreen.kt | 1 + app/src/main/res/values-de/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + .../calendula/domain/EventFormTest.kt | 49 ++- 9 files changed, 557 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 44c25c3..2d958e9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm @@ -536,6 +537,10 @@ class AndroidCalendarDataSource @Inject constructor( Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId") } } + // Guests are best-effort like reminders: a row that fails to attach is + // logged, not surfaced as a failed create. Calendula never sends an + // invitation — it only writes the rows; the backend decides delivery. + insertAttendees(eventId, form.attendees) return eventId } @@ -565,6 +570,12 @@ class AndroidCalendarDataSource @Inject constructor( if (updated.reminders.toSet() != original.reminders.toSet()) { reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes)) } + // Same untouched-set guard for guests: only reconcile when the form's + // attendees actually changed, so unrelated edits never disturb the + // organizer/resource rows the form doesn't model. + if (updated.attendees.toSet() != original.attendees.toSet()) { + reconcileAttendees(eventId, updated.attendees) + } } override fun updateOccurrence( @@ -587,6 +598,7 @@ class AndroidCalendarDataSource @Inject constructor( // Whether the provider copied the parent's reminder rows is its // business — reconciling against the actual rows handles both ways. reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes)) + reconcileAttendees(exceptionId, form.attendees) return exceptionId } @@ -720,6 +732,135 @@ class AndroidCalendarDataSource @Inject constructor( } } + /** Normalised key an attendee is matched/deduped on (case-insensitive email). */ + private fun EventAttendee.key(): String = email.trim().lowercase() + + /** Distinct guests by email; blanks dropped (we key and write on the address). */ + private fun List.distinctByEmail(): List { + val seen = HashSet() + return filter { it.email.isNotBlank() && seen.add(it.key()) } + } + + private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues = + ContentValues().apply { + put(CalendarContract.Attendees.EVENT_ID, eventId) + put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim()) + attendee.name.trim().takeIf { it.isNotEmpty() } + ?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) } + // A plain guest — never the organizer (we don't fabricate scheduling + // identity; the backend owns that). Invited = awaiting their reply. + put( + CalendarContract.Attendees.ATTENDEE_RELATIONSHIP, + CalendarContract.Attendees.RELATIONSHIP_ATTENDEE, + ) + put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType()) + put( + CalendarContract.Attendees.ATTENDEE_STATUS, + CalendarContract.Attendees.ATTENDEE_STATUS_INVITED, + ) + } + + private fun EventAttendee.providerType(): Int = if (optional) { + CalendarContract.Attendees.TYPE_OPTIONAL + } else { + CalendarContract.Attendees.TYPE_REQUIRED + } + + /** Best-effort attendee inserts (like reminders); emails are never logged. */ + private fun insertAttendees(eventId: Long, attendees: List) { + attendees.distinctByEmail().forEach { attendee -> + if (resolver.insert( + CalendarContract.Attendees.CONTENT_URI, + attendeeValues(eventId, attendee), + ) == null + ) { + Log.w(TAG, "Failed to attach a guest to event $eventId") + } + } + } + + private data class AttendeeRow( + val id: Long, + val email: String, + val relationship: Int, + val type: Int, + ) { + val key: String get() = email.trim().lowercase() + } + + private fun queryAttendeeRows(eventId: Long): List = resolver.query( + CalendarContract.Attendees.CONTENT_URI, + arrayOf( + CalendarContract.Attendees._ID, + CalendarContract.Attendees.ATTENDEE_EMAIL, + CalendarContract.Attendees.ATTENDEE_RELATIONSHIP, + CalendarContract.Attendees.ATTENDEE_TYPE, + ), + CalendarContract.Attendees.EVENT_ID + " = ?", + arrayOf(eventId.toString()), + null, + )?.use { c -> + buildList { + while (c.moveToNext()) { + add( + AttendeeRow( + id = c.getLong(0), + email = c.getString(1).orEmpty(), + relationship = c.getInt(2), + type = c.getInt(3), + ), + ) + } + } + } ?: emptyList() + + /** + * Make the event's *editable* guest rows match [attendees]. Only rows we + * own — has an email, not the organizer, not a resource — are reconciled; + * everything else the backend put there is left untouched. Matched rows keep + * their response status (only the required/optional type is updated if it + * changed); dropped rows are deleted; new guests inserted. Calendula sends + * nothing — the backend decides if anyone is notified on sync. + */ + private fun reconcileAttendees(eventId: Long, attendees: List) { + val target = attendees.distinctByEmail() + val targetByKey = target.associateBy { it.key() } + val editable = queryAttendeeRows(eventId).filter { + it.email.isNotBlank() && + it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER && + it.type != CalendarContract.Attendees.TYPE_RESOURCE + } + val editableKeys = editable.map { it.key }.toSet() + editable.forEach { row -> + val want = targetByKey[row.key] + if (want == null) { + resolver.delete( + CalendarContract.Attendees.CONTENT_URI, + CalendarContract.Attendees._ID + " = ?", + arrayOf(row.id.toString()), + ) + } else if (row.type != want.providerType()) { + resolver.update( + CalendarContract.Attendees.CONTENT_URI, + ContentValues().apply { + put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType()) + }, + CalendarContract.Attendees._ID + " = ?", + arrayOf(row.id.toString()), + ) + } + } + target.filterNot { it.key() in editableKeys }.forEach { attendee -> + if (resolver.insert( + CalendarContract.Attendees.CONTENT_URI, + attendeeValues(eventId, attendee), + ) == null + ) { + Log.w(TAG, "Failed to attach a guest to event $eventId") + } + } + } + private fun Map.toContentValues(): ContentValues = ContentValues().also { cv -> forEach { (column, value) -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 3cfdc40..5acc612 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -41,6 +41,27 @@ data class EventForm( */ val colorKey: String? = null, val color: Int? = null, + /** + * Guests the user has added/kept on the event. Calendula only writes these + * `Attendees` rows — it has no INTERNET and never sends an invitation + * itself; whether a guest is notified is decided downstream by the + * calendar's backend (local: no one; CalDAV/Google: the server/account). + * Read-only rows the form doesn't model (the organizer, resources) are + * preserved by the data layer, not carried here. + */ + val attendees: List = emptyList(), +) + +/** + * One editable guest: the user controls the [email] (the identity we dedup and + * write on), an optional display [name], and whether they're [optional] rather + * than required. Response status and the organizer/resource distinction are not + * user-editable, so they aren't modelled here. + */ +data class EventAttendee( + val email: String, + val name: String = "", + val optional: Boolean = false, ) /** @@ -55,6 +76,7 @@ enum class EventFormField { Availability, Visibility, Color, + Attendees, } enum class EventFormProblem { @@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): // calendar's colour. colorKey = eventColorKey, color = eventColor, + // Only editable guests ride in the form: drop the organizer and + // resource rows (not user-editable) and any without an email (we key + // edits and dedup on the address). The data layer preserves the rows we + // don't carry here, so they're never clobbered on save. + attendees = attendees + .filter { + it.relationship != AttendeeRelationship.Organizer && + it.type != AttendeeType.Resource + } + .mapNotNull { a -> + a.email?.takeIf { it.isNotBlank() }?.let { email -> + EventAttendee( + email = email, + name = a.name, + optional = a.type == AttendeeType.Optional, + ) + } + }, ) } @@ -117,9 +157,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): * while the form was open. The raw row times ride along because * [toEditForm] derives the form's times from the *tapped occurrence*, so * re-deriving with the same occurrence would mask an externally moved - * event. Not covered (the form can't write them, and the dirty-checked - * write can't clobber them): attendees, status, the user's own response, - * reminder methods, and a recurring event's duration. + * event. Guests are covered (the form writes editable attendees), so an + * external attendee change now also trips the conflict check. Still not + * covered: status, the user's own response, reminder methods, the + * organizer/resource rows, and a recurring event's duration. */ data class EditSnapshot( val form: EventForm, @@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set = buildSet { if (availability != Availability.Busy) add(EventFormField.Availability) if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility) if (colorKey != null || color != null) add(EventFormField.Color) + if (attendees.isNotEmpty()) add(EventFormField.Attendees) } fun EventForm.problems(): Set = buildSet { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt index 85105e0..6a541f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -12,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -36,6 +38,9 @@ fun InlineTextField( minLines: Int = 1, keyboardType: KeyboardType = KeyboardType.Text, capitalization: KeyboardCapitalization = KeyboardCapitalization.None, + imeAction: ImeAction = ImeAction.Default, + /** Invoked when the IME action key (e.g. Done) is pressed. */ + onImeAction: (() -> Unit)? = null, ) { val resolvedStyle = textStyle.copy( color = if (textStyle.color.isSpecified) { @@ -53,7 +58,11 @@ fun InlineTextField( keyboardOptions = KeyboardOptions( keyboardType = keyboardType, capitalization = capitalization, + imeAction = imeAction, ), + keyboardActions = onImeAction?.let { action -> + KeyboardActions(onAny = { action() }) + } ?: KeyboardActions.Default, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), decorationBox = { innerTextField -> Box { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 3f9866e..a762c2b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.edit import android.Manifest import android.content.pm.PackageManager +import android.util.Patterns import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -30,7 +31,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Notes @@ -42,6 +42,9 @@ import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat @@ -49,11 +52,11 @@ import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -82,13 +85,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -97,6 +104,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField @@ -115,6 +123,8 @@ import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.OptionCard +import de.jeanlucmakiola.calendula.ui.common.Position +import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.ReminderUnit import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert @@ -587,33 +597,24 @@ private fun EventEditContent( OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) { Spacer(Modifier.height(gap)) - // Reminders stack one per row (remove on the right edge); the - // add chip closes the card at the bottom. The card icon centres - // on the first row, whatever that is. - EditCard( - icon = Icons.Default.Notifications, - iconContentDescription = stringResource(R.string.event_detail_reminders), - header = { - when (val first = form.reminders.firstOrNull()) { - null -> AddReminderChip(onClick = { showReminderPicker = true }) - else -> ReminderRow( - label = reminderLabel(first), - onRemove = { viewModel.removeReminder(first) }, - ) - } - }, - ) { - form.reminders.drop(1).forEach { minutes -> - ReminderRow( - label = reminderLabel(minutes), - onRemove = { viewModel.removeReminder(minutes) }, - ) - } - if (form.reminders.isNotEmpty()) { - Spacer(Modifier.height(4.dp)) - AddReminderChip(onClick = { showReminderPicker = true }) - } + // A grouped list like guests: each reminder is its own tonal card + // with connected corners, the last row being "Add reminder". + val reminders = form.reminders + val rows = reminders.size + 1 + reminders.forEachIndexed { index, minutes -> + GroupedItemCard( + position = positionOf(index, rows), + icon = Icons.Default.Notifications, + title = reminderLabel(minutes), + removeContentDescription = stringResource(R.string.event_edit_remove_reminder), + onRemove = { viewModel.removeReminder(minutes) }, + ) } + AddActionCard( + label = stringResource(R.string.event_edit_add_reminder), + position = positionOf(reminders.size, rows), + onClick = { showReminderPicker = true }, + ) } OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) { @@ -776,6 +777,42 @@ private fun EventEditContent( } } + OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) { + Spacer(Modifier.height(gap)) + // Guests are an inline grouped list: each is a tonal card with a + // role toggle + remove, and the trailing card is an inline email + // field — type an address, press Done, it commits. Below sits an + // honest note — Calendula never sends invitations, so whether a + // guest is notified is the calendar backend's call. + val guests = form.attendees + val rows = guests.size + 1 + guests.forEachIndexed { index, attendee -> + GuestCard( + attendee = attendee, + position = positionOf(index, rows), + onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) }, + onRemove = { viewModel.removeAttendee(attendee) }, + ) + } + AddGuestInlineCard( + position = positionOf(guests.size, rows), + onAdd = { viewModel.addAttendee(it) }, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + if (isLocalCalendar) { + R.string.event_edit_attendees_note_local + } else { + R.string.event_edit_attendees_note_synced + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { Spacer(Modifier.height(20.dp)) TextButton( @@ -840,6 +877,7 @@ private fun EventEditContent( ) } + if (showRecurrencePicker) { RecurrencePickerDialog( current = form.rrule, @@ -1262,45 +1300,222 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) { RecurrenceFreq.Yearly -> R.string.recurrence_unit_years } -/** One chosen reminder: humanised lead time, remove pinned to the right edge. */ -@Composable -private fun ReminderRow(label: String, onRemove: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), +/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */ +private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape = + when (position) { + Position.Alone -> RoundedCornerShape(full) + Position.Top -> RoundedCornerShape( + topStart = full, topEnd = full, bottomStart = small, bottomEnd = small, ) - IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Position.Middle -> RoundedCornerShape(small) + Position.Bottom -> RoundedCornerShape( + topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, + ) + } + +/** The 2dp gap that visually separates grouped cards (none after the last). */ +private fun groupedGap(position: Position): Modifier = when (position) { + Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) + Position.Bottom, Position.Alone -> Modifier +} + +/** + * One entry in a grouped form list (a guest, a reminder): a leading [icon], + * a [title] with optional [supporting] line, and a remove button — all on a + * tonal card whose corners come from its [position] in the run. + */ +@Composable +private fun GroupedItemCard( + position: Position, + icon: ImageVector, + title: String, + removeContentDescription: String, + onRemove: () -> Unit, + supporting: String? = null, + titleMaxLines: Int = Int.MAX_VALUE, + trailing: @Composable (() -> Unit)? = null, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + ) { Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.event_edit_remove_reminder), - modifier = Modifier.size(18.dp), + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + ) + if (supporting != null) { + Text( + text = supporting, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (trailing != null) { + Spacer(Modifier.width(8.dp)) + trailing() + } + IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = removeContentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +/** + * One guest as a grouped-list card: avatar glyph, name/email, a tappable + * Required/Optional role chip, and remove. The email shows as the supporting + * line when a name is present, otherwise as the title. + */ +@Composable +private fun GuestCard( + attendee: EventAttendee, + position: Position, + onToggleOptional: (Boolean) -> Unit, + onRemove: () -> Unit, +) { + val hasName = attendee.name.isNotBlank() + GroupedItemCard( + position = position, + icon = Icons.Default.Person, + title = if (hasName) attendee.name else attendee.email, + supporting = attendee.email.takeIf { hasName }, + titleMaxLines = 1, + removeContentDescription = stringResource(R.string.event_edit_remove_guest), + onRemove = onRemove, + trailing = { + FilterChip( + selected = attendee.optional, + onClick = { onToggleOptional(!attendee.optional) }, + label = { + Text( + stringResource( + if (attendee.optional) { + R.string.event_edit_attendee_optional + } else { + R.string.event_edit_attendee_required + }, + ), + ) + }, + ) + }, + ) +} + +/** The trailing "Add …" action card of a grouped form list (opens a picker). */ +@Composable +private fun AddActionCard(label: String, position: Position, onClick: () -> Unit) { + Surface( + onClick = onClick, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, ) } } } +/** + * The trailing card of the guests list: an inline email field. Type an address + * and press Done (or tap the add button) to commit it as a guest; the field + * clears, ready for the next. Matches the form's inline-field input style — no + * dialog. + */ @Composable -private fun AddReminderChip(onClick: () -> Unit) { - AssistChip( - onClick = onClick, - label = { Text(stringResource(R.string.event_edit_add)) }, - leadingIcon = { +private fun AddGuestInlineCard(position: Position, onAdd: (String) -> Unit) { + var text by rememberSaveable { mutableStateOf("") } + val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() } + fun commit() { + if (valid) { + onAdd(text.trim()) + text = "" + } + } + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + ) { Icon( - imageVector = Icons.Default.Add, + imageVector = Icons.Default.PersonAdd, contentDescription = null, - modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), ) - }, - border = null, - modifier = Modifier.fillMaxWidth(), - ) + Spacer(Modifier.width(16.dp)) + InlineTextField( + value = text, + onValueChange = { text = it }, + placeholder = stringResource(R.string.event_edit_add_guest_hint), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + onImeAction = { commit() }, + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + ) + if (valid) { + IconButton(onClick = { commit() }, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.event_edit_add_guest), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } + } + } + } } + private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location EventFormField.Description -> R.string.event_detail_description @@ -1309,6 +1524,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Color -> R.string.event_edit_color + EventFormField.Attendees -> R.string.event_edit_attendees } private fun fieldIcon(field: EventFormField): ImageVector = when (field) { @@ -1319,6 +1535,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Availability -> Icons.Default.EventAvailable EventFormField.Visibility -> Icons.Default.Lock EventFormField.Color -> Icons.Default.Palette + EventFormField.Attendees -> Icons.Default.People } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index d30ece4..e878330 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EditSnapshot +import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField @@ -353,6 +354,33 @@ class EventEditViewModel @Inject constructor( update { it.copy(reminders = it.reminders - minutes) } } + /** + * Add a guest, keyed on a trimmed lower-cased email so re-adding the same + * address just refreshes it (no duplicate row). A blank email is ignored — + * the email is what we write and dedup on. + */ + fun addAttendee(email: String, name: String = "", optional: Boolean = false) { + val trimmed = email.trim() + if (trimmed.isEmpty()) return + update { form -> + val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional) + val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) } + form.copy(attendees = rest + attendee) + } + } + + fun removeAttendee(attendee: EventAttendee) = + update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) } + + /** Flip a guest between required and optional (the per-row role toggle). */ + fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form -> + form.copy( + attendees = form.attendees.map { a -> + if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a + }, + ) + } + /** Moving the start drags the end along, preserving the duration. */ fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) } fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 4d1a06f..ced7652 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -872,6 +872,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Color -> R.string.event_edit_color + EventFormField.Attendees -> R.string.event_edit_attendees } @Composable diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4ec4c11..af76e2e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -77,6 +77,14 @@ Hinzufügen Erinnerung hinzufügen Erinnerung entfernen + Gäste + Gast hinzufügen + Gast per E-Mail hinzufügen… + Gast entfernen + Erforderlich + Optional + Calendula versendet keine Einladungen. Dein Kalenderkonto sendet Gästen bei der Synchronisierung unter Umständen eine E-Mail. + Auf diesem Gerät gespeichert. Niemand wird benachrichtigt. Benutzerdefiniert Minuten Stunden diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7801091..301596b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -78,6 +78,14 @@ Add Add reminder Remove reminder + Guests + Add guest + Add a guest by email… + Remove guest + Required + Optional + Calendula doesn\'t send invitations. Your calendar account may email guests when it syncs. + Stored on this device. No one is notified. Custom minutes hours diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index c6a9066..fc975c6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -211,12 +211,55 @@ class EventFormTest { } @Test - fun `changes the form cannot write do not fake a conflict`() { + fun `a reminder-method change the form cannot write does not fake a conflict`() { + // The form carries reminder minutes only, not the method — so a method + // flip the form can't express must not read as an external change. + val loaded = detail(reminders = listOf(Reminder(10, ReminderMethod.Alert))) + .toEditSnapshot(0L, 3_600_000L, berlin) + val fresh = detail(reminders = listOf(Reminder(10, ReminderMethod.Email))) + .toEditSnapshot(0L, 3_600_000L, berlin) + assertThat(fresh).isEqualTo(loaded) + } + + @Test + fun `an external guest change now trips the conflict check`() { + // Attendees are writable now, so they ride in the snapshot: adding a + // guest externally is a real conflict, not invisible. val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin) val fresh = detail( - attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)), + attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction)), ).toEditSnapshot(0L, 3_600_000L, berlin) - assertThat(fresh).isEqualTo(loaded) + assertThat(fresh).isNotEqualTo(loaded) + } + + @Test + fun `toEditForm carries editable guests with name email and optional role`() { + val prefilled = detail( + attendees = listOf( + Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted, type = AttendeeType.Required), + Attendee("", "bob@example.org", AttendeeStatus.NeedsAction, type = AttendeeType.Optional), + ), + ).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin) + assertThat(prefilled.attendees).containsExactly( + EventAttendee(email = "ada@example.org", name = "Ada", optional = false), + EventAttendee(email = "bob@example.org", name = "", optional = true), + ).inOrder() + assertThat(prefilled.populatedFields()).contains(EventFormField.Attendees) + } + + @Test + fun `toEditForm drops the organizer, resources and guests without an email`() { + val prefilled = detail( + attendees = listOf( + Attendee("Org", "org@example.org", AttendeeStatus.Accepted, AttendeeRelationship.Organizer), + Attendee("Room A", "room@example.org", AttendeeStatus.Accepted, type = AttendeeType.Resource), + Attendee("No address", null, AttendeeStatus.NeedsAction), + Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction), + ), + ).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin) + assertThat(prefilled.attendees).containsExactly( + EventAttendee(email = "ada@example.org", name = "Ada", optional = false), + ) } @Test