Merge pull request 'feat: editable event attendees with contact picker' (!29) from feat/attendee-editing into release/v2.8.0

Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
2026-06-23 07:12:28 +00:00
12 changed files with 793 additions and 110 deletions

View File

@@ -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) ->

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -590,10 +590,22 @@ private fun AttendeeRow(attendee: Attendee) {
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
val hasName = attendee.name.isNotBlank()
Text(
text = attendee.name.ifBlank { attendee.email.orEmpty() },
text = if (hasName) attendee.name else attendee.email.orEmpty(),
style = MaterialTheme.typography.bodyMedium,
)
// Email as supporting text — only when the name is the headline, so we
// don't repeat it on rows that already fall back to the email above.
if (hasName) {
attendee.email?.takeIf { it.isNotBlank() }?.let { email ->
Text(
text = email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
attendeeRoleLabel(attendee)?.let { roleRes ->
Text(
text = stringResource(roleRes),

View File

@@ -1,7 +1,13 @@
package de.jeanlucmakiola.calendula.ui.edit
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.util.Patterns
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -30,7 +36,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
@@ -38,10 +43,14 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth
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.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 +58,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 +91,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 +110,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 +129,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
@@ -429,6 +445,22 @@ private fun EventEditContent(
var showColorPicker 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 }
// The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour.
@@ -587,33 +619,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 +799,52 @@ 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) },
onPickContact = {
runCatching {
pickContact.launch(
Intent(
Intent.ACTION_PICK,
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
),
)
}
},
)
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 +909,7 @@ private fun EventEditContent(
)
}
if (showRecurrencePicker) {
RecurrencePickerDialog(
current = form.rrule,
@@ -1262,45 +1332,253 @@ 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 plus a "from
* contacts" button. Type an address and press Done (or tap +) to commit a guest;
* or pick from contacts to fill email + name at once. The field clears after,
* 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,
onPickContact: () -> 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),
)
}
}
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) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
@@ -1309,6 +1587,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 +1598,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
}
/**

View File

@@ -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) }

View File

@@ -905,6 +905,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

View File

@@ -77,6 +77,15 @@
<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_add_guest_from_contacts">Aus Kontakten 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>

View File

@@ -78,6 +78,15 @@
<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_add_guest_from_contacts">Add from contacts</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>

View File

@@ -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