feat(edit): make event attendees editable
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<EventAttendee>.distinctByEmail(): List<EventAttendee> {
|
||||
val seen = HashSet<String>()
|
||||
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<EventAttendee>) {
|
||||
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<AttendeeRow> = 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<EventAttendee>) {
|
||||
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<String, Any?>.toContentValues(): ContentValues =
|
||||
ContentValues().also { cv ->
|
||||
forEach { (column, value) ->
|
||||
|
||||
@@ -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<EventAttendee> = 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<EventFormField> = 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<EventFormProblem> = buildSet {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,6 +77,14 @@
|
||||
<string name="event_edit_add">Hinzufügen</string>
|
||||
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
|
||||
<string name="event_edit_remove_reminder">Erinnerung entfernen</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_hint">Gast per E-Mail hinzufügen…</string>
|
||||
<string name="event_edit_remove_guest">Gast entfernen</string>
|
||||
<string name="event_edit_attendee_required">Erforderlich</string>
|
||||
<string name="event_edit_attendee_optional">Optional</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula versendet keine Einladungen. Dein Kalenderkonto sendet Gästen bei der Synchronisierung unter Umständen eine E-Mail.</string>
|
||||
<string name="event_edit_attendees_note_local">Auf diesem Gerät gespeichert. Niemand wird benachrichtigt.</string>
|
||||
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
|
||||
<string name="reminder_unit_minutes">Minuten</string>
|
||||
<string name="reminder_unit_hours">Stunden</string>
|
||||
|
||||
@@ -78,6 +78,14 @@
|
||||
<string name="event_edit_add">Add</string>
|
||||
<string name="event_edit_add_reminder">Add reminder</string>
|
||||
<string name="event_edit_remove_reminder">Remove reminder</string>
|
||||
<string name="event_edit_attendees">Guests</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_remove_guest">Remove guest</string>
|
||||
<string name="event_edit_attendee_required">Required</string>
|
||||
<string name="event_edit_attendee_optional">Optional</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula doesn\'t send invitations. Your calendar account may email guests when it syncs.</string>
|
||||
<string name="event_edit_attendees_note_local">Stored on this device. No one is notified.</string>
|
||||
<string name="event_edit_reminder_custom">Custom</string>
|
||||
<string name="reminder_unit_minutes">minutes</string>
|
||||
<string name="reminder_unit_hours">hours</string>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user