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

@@ -236,22 +236,34 @@ pass on the existing controls; new toggles ride in with their own features.
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global 9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
default reminder **+ per-calendar override**, bundled with battery-exemption default reminder **+ per-calendar override**, bundled with battery-exemption
hardening. Full sketch in "Reminders — defaults & delivery reliability" below. hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
10. **The `.ics` engine — export + import** *(in progress → v2.7)* — one 10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`), hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
four surfaces: single-event share + whole-calendar backup (export), four surfaces all shipped: single-event share + whole-calendar backup
open-`.ics`→form + whole-calendar restore (import). Closes the (export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
device-local-calendar data-loss gap (#10/#11 merged here). Built as **two device-local-calendar data-loss gap (#10/#11 merged here). Built as two
sequential branches in one release**: `feat/ics-export` (write side + sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup). then `feat/ics-import` (parser, restore, dedup by UID). Import is
Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
`RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`, / guest lists it can't model. Plans:
non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored `docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
`EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on 11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)*
import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`. followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the decision (snooze must re-fire an alarm).
`.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze
must re-fire an alarm). **Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog) 12. **Attendee editing** *(next — promoted from the gated "Locations & People"
bucket, owner says high-importance 2026-06-22)* — attendees are already
*read* (queried, mapped, shown on the detail screen since v0.6) but the
event form can't *write* them, the last big read-only gap in the event
model. Add an attendees section to `EventEditScreen` / `EventForm`: add by
typed email (contact-picker entry can follow later), edit/remove rows, set
role (required / optional) — writing `CalendarContract.Attendees` rows on
insert + dirty-checked update, mirroring the reminders-diff pattern.
**Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter
*invitation* behavior that differs by backend (Google auto-emails invites;
DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) —
decide and document what Calendula promises before building. Full sketch in
"Attendee editing" under Locations & People below.
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)** **Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage) - Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
@@ -263,11 +275,13 @@ pass on the existing controls; new toggles ride in with their own features.
2026-06-17; cheap but low value, pick up only if asked 2026-06-17; cheap but low value, pick up only if asked
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts, **Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
full-text search, ICS file import. Pulled in opportunistically, not sequenced. full-text search. Pulled in opportunistically, not sequenced.
Debatable calls worth a second look: whether **local-calendar backup (#10)** Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
should lead Tier 4 outright (it's a silent data-loss risk, not a feature); export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected).
whether drag-drop (#12) jumps ahead given its daily-driver impact. **Next committed work is Tier 5 #12 — attendee editing** (the last read-only gap
in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
(quick-settings tile, now-line, week numbers in month, full-text search).
## Navigation & views ## Navigation & views
@@ -288,8 +302,6 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact.
## Event editing & creation ## Event editing & creation
- Drag & drop rescheduling in day/week (recurring drops reuse the scope
dialog) — big-ticket, own slice
- Duplicate event (detail action → prefilled create form) - Duplicate event (detail action → prefilled create form)
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form) - **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
*(next)* — chosen to follow the in-progress tap-to-create + calendar *(next)* — chosen to follow the in-progress tap-to-create + calendar
@@ -319,13 +331,11 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact.
go/no-go gate as the OSM/INTERNET item below. go/no-go gate as the OSM/INTERNET item below.
- Move event to another calendar (copy+delete model with a consequences - Move event to another calendar (copy+delete model with a consequences
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)* warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
- **Local-calendar backup / export** *(Tier 4 #10)* — device-only - ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0**
(`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
sync and therefore no backup**: a lost/wiped phone destroys them permanently. backup. Settings → Calendars → Export writes every event to a user-chosen
Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus `.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
restore-on-import that recreates events into a chosen local calendar. Reuses UID). Closed the silent data-loss gap.
the .ics serializer from the single-event share work; the restore path reuses
the import parser. A data-integrity obligation, not a feature.
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)* ## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
@@ -419,9 +429,11 @@ it directly undermines the feature's premise, so it rides in here.
## Sharing & interop ## Sharing & interop
- Share event as .ics + open/receive .ics into a prefilled create form - ~~Share event as .ics + open/receive .ics into a prefilled create form~~
(front-runs the import below) **shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
- ICS file import (drag-and-drop) *(was v3.0, optional)* event prefills the create form, many events opens a bulk import (dedup by UID)
- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics`
flow above (single → form, many → bulk import)
## Platform & launchers ## Platform & launchers
@@ -455,9 +467,57 @@ in detail yet:
trade-off is an explicit go/no-go decision before any work starts. trade-off is an explicit go/no-go decision before any work starts.
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only - **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
if the picker proves clunky. if the picker proves clunky.
- **Attendee editing / invites from contacts** — own milestone; writing - **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now
`Attendees` rows touches sync-adapter invitation behavior (Google vs Tier 5 #12, high-importance; the no-permission typed-email path is not an
DAVx5 differ). INTERNET/contacts call)*. See the "Attendee editing" sketch below.
### Attendee editing *(Tier 5 #12, opened 2026-06-22)*
The last read-only gap in the event model: attendees are read & shown on the
detail screen (since v0.6) but the form can't write them. Make guests editable.
- **Read side already done:** `Attendee` domain model + status/relationship/type
enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the
attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add
there.
- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):**
`attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an
inline grouped list — each guest a tonal card (avatar, name/email, tappable
Required/Optional role chip, remove), the trailing card an inline email field
(type → Done commits). Persisted by `reconcileAttendees` diffing the provider's
`CalendarContract.Attendees` rows on insert + dirty-checked update (new guests =
`RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the
required/optional type updates; organizer/resource/no-email rows untouched).
Reminders restyled to the same grouped-list pattern in passing. Needs only the
existing `WRITE_CALENDAR` — no new permission.
- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
field is email-only (the quick path); the add row also has a **"from contacts"
button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI`
the system Contacts app returns a URI to the picked email row, queried for
`Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
email and name in one tap. The result Intent grants temporary read access, so
**no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
mechanism as the location address picker above. Self/organizer rows stay
non-editable.
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
calendars.** Calendula has no INTERNET and never sends an invitation itself; it
only writes `Attendees` rows. Whether a guest is notified is decided downstream:
local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines
and the *server* decides iMIP delivery; Google's sync adapter pushes the change
and Google decides (third-party attendee writes are historically unreliable
there). Editing is allowed on **any writable calendar** — not gated to local.
- **Honest, backend-aware copy is mandatory** (this is the whole point of the
decision): on a synced calendar show "Calendula doesn't send invitations —
your calendar account may email guests when it syncs"; on a local calendar
show "Stored on this device. No one is notified."
- Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling
state to coax a send — it writes the guest list faithfully and leaves
scheduling entirely to the backend.
- The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`,
still no INTERNET) was considered and **deferred** — revisit only if users ask
to notify guests explicitly.
- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups,
resource booking — all carry server round-trips or richer sync semantics.
## Consciously rejected ## Consciously rejected
@@ -465,3 +525,5 @@ in detail yet:
- Natural-language quick entry (high effort, locale-fragile; the prefilled - Natural-language quick entry (high effort, locale-fragile; the prefilled
form already covers fast entry) form already covers fast entry)
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0) - Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
- Drag & drop rescheduling in day/week — **rejected** (owner decision,
reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form.

View File

@@ -1,17 +1,27 @@
# Calendula — Current State # Calendula — Current State
*Last updated: 2026-06-17* *Last updated: 2026-06-22*
## Status ## Status
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11; **Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15. **Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local **Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings, interop) arc is now done or in flight:
the calendar manager and the navigation drawer) both shipped 2026-06-16; - v2.4.0 per-event colors (2026-06-17)
v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen - v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
agenda + month widgets, and a "New event" launcher shortcut) shipped event" launcher shortcut (2026-06-17)
2026-06-17. The backlog is now organised by theme in `ROADMAP.md`. - v2.6.0 default reminders (global + per-calendar override, all-day default,
battery-exemption row) + system per-app language (2026-06-18)
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
(2026-06-18)
- v2.7.1v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes
**Next release `release/v2.8.0` (integration branch, not yet cut to main):**
holds crash reports via the public Codeberg tracker (MR !27) + reminder
snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at
release-cut.
## Progress ## Progress
@@ -120,11 +130,48 @@ agenda + month widgets, and a "New event" launcher shortcut) shipped
sync" warning on the picker and in Settings. Color writes flow through sync" warning on the picker and in Settings. Color writes flow through
insert / dirty-checked update / occurrence-exception; mapper + form tests. insert / dirty-checked update / occurrence-exception; mapper + form tests.
- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view),
jump-to-date drawer date picker, two home-screen widgets (scrolling
"Upcoming" agenda + month grid), and a "New event" launcher long-press
shortcut
- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default +
separate all-day default + per-calendar override (timed), applied on create
with dirty-flag handling; three pickers + override list in Settings →
Notifications; battery-optimisation exemption row (status + system deep-link,
no new permission). Plus system per-app language (Android 13+) and an
immediate-effect fix for the in-app language picker
- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as
`.ics` from the detail screen; back up local calendars (Settings → Calendars
→ Export) to a SAF file; open/receive an `.ics` — one event prefills the
create form, many events open a bulk import into a chosen calendar (dedup by
UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests).
Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and
a widget R8 keep-rule crash fix
- [x] v2.7.1v2.7.5 (2026-06-21) — launch crash fix (listener before grant),
user-controlled crash reporting, widget loading-spinner R8 keep rule, and
F-Droid reproducible-build cleanups for the official repo
- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg
tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28,
snooze self-schedules an exact alarm; primary delivery stays provider-broadcast)
## Next ## Next
1. Monitor the F-Droid build/publish for the v2.4.0 tag 1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
2. Decide the "Locations & People" and "remote calendar create/edit" merge-driven pipeline) once on-device review signs off
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md` 2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then importance, opened 2026-06-22). Attendees are already read & shown on the
agenda view (strategic, backs a future widget). Full ranked sequence in detail screen since v0.6; the gap is the write side — make guests editable
`ROADMAP.md` → "Near-term sequence". in `EventEditScreen` / `EventForm` (add by typed email, role, remove),
persisted by diffing `CalendarContract.Attendees`. No new permission for the
typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all
writable calendars** — Calendula never sends (no INTERNET); honest backend-aware
copy ("your account may email guests when it syncs" on synced calendars, "no one
is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing".
3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar
create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week
numbers in month, full-text search, accessibility pass). Drag-and-drop
rescheduling is **rejected**.

View File

@@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm 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") 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 return eventId
} }
@@ -565,6 +570,12 @@ class AndroidCalendarDataSource @Inject constructor(
if (updated.reminders.toSet() != original.reminders.toSet()) { if (updated.reminders.toSet() != original.reminders.toSet()) {
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes)) 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( override fun updateOccurrence(
@@ -587,6 +598,7 @@ class AndroidCalendarDataSource @Inject constructor(
// Whether the provider copied the parent's reminder rows is its // Whether the provider copied the parent's reminder rows is its
// business — reconciling against the actual rows handles both ways. // business — reconciling against the actual rows handles both ways.
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes)) reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
reconcileAttendees(exceptionId, form.attendees)
return exceptionId 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 = private fun Map<String, Any?>.toContentValues(): ContentValues =
ContentValues().also { cv -> ContentValues().also { cv ->
forEach { (column, value) -> forEach { (column, value) ->

View File

@@ -41,6 +41,27 @@ data class EventForm(
*/ */
val colorKey: String? = null, val colorKey: String? = null,
val color: Int? = 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, Availability,
Visibility, Visibility,
Color, Color,
Attendees,
} }
enum class EventFormProblem { enum class EventFormProblem {
@@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
// calendar's colour. // calendar's colour.
colorKey = eventColorKey, colorKey = eventColorKey,
color = eventColor, 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 * while the form was open. The raw row times ride along because
* [toEditForm] derives the form's times from the *tapped occurrence*, so * [toEditForm] derives the form's times from the *tapped occurrence*, so
* re-deriving with the same occurrence would mask an externally moved * re-deriving with the same occurrence would mask an externally moved
* event. Not covered (the form can't write them, and the dirty-checked * event. Guests are covered (the form writes editable attendees), so an
* write can't clobber them): attendees, status, the user's own response, * external attendee change now also trips the conflict check. Still not
* reminder methods, and a recurring event's duration. * covered: status, the user's own response, reminder methods, the
* organizer/resource rows, and a recurring event's duration.
*/ */
data class EditSnapshot( data class EditSnapshot(
val form: EventForm, val form: EventForm,
@@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (availability != Availability.Busy) add(EventFormField.Availability) if (availability != Availability.Busy) add(EventFormField.Availability)
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility) if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
if (colorKey != null || color != null) add(EventFormField.Color) if (colorKey != null || color != null) add(EventFormField.Color)
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
} }
fun EventForm.problems(): Set<EventFormProblem> = buildSet { 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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.SolidColor
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.TextStyle 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.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -36,6 +38,9 @@ fun InlineTextField(
minLines: Int = 1, minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text, keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.None, 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( val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) { color = if (textStyle.color.isSpecified) {
@@ -53,7 +58,11 @@ fun InlineTextField(
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = keyboardType, keyboardType = keyboardType,
capitalization = capitalization, capitalization = capitalization,
imeAction = imeAction,
), ),
keyboardActions = onImeAction?.let { action ->
KeyboardActions(onAny = { action() })
} ?: KeyboardActions.Default,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
Box { Box {

View File

@@ -590,10 +590,22 @@ private fun AttendeeRow(attendee: Attendee) {
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
val hasName = attendee.name.isNotBlank()
Text( Text(
text = attendee.name.ifBlank { attendee.email.orEmpty() }, text = if (hasName) attendee.name else attendee.email.orEmpty(),
style = MaterialTheme.typography.bodyMedium, 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 -> attendeeRoleLabel(attendee)?.let { roleRes ->
Text( Text(
text = stringResource(roleRes), text = stringResource(roleRes),

View File

@@ -1,7 +1,13 @@
package de.jeanlucmakiola.calendula.ui.edit package de.jeanlucmakiola.calendula.ui.edit
import android.Manifest import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.util.Patterns
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes 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.ArrowDropDown
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette 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.Place
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat 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.Tune
import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton 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.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight 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.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel 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.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField 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.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard 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.REMINDER_PRESETS
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
@@ -429,6 +445,22 @@ private fun EventEditContent(
var showColorPicker by rememberSaveable { mutableStateOf(false) } var showColorPicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) }
// Pick a guest from contacts: a one-shot system picker returning the chosen
// email row (email + display name). No READ_CONTACTS — the result Intent
// grants temporary read access, so Calendula stays no-network/no-permission.
val context = LocalContext.current
val pickContact = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
readContactEmail(context, uri)?.let { (email, name) ->
viewModel.addAttendee(email, name)
}
}
}
}
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
// The accent ties the form to the detail screen's design language: the // The accent ties the form to the detail screen's design language: the
// bar under the title takes the target calendar's colour. // bar under the title takes the target calendar's colour.
@@ -587,33 +619,24 @@ private fun EventEditContent(
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) { OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
// Reminders stack one per row (remove on the right edge); the // A grouped list like guests: each reminder is its own tonal card
// add chip closes the card at the bottom. The card icon centres // with connected corners, the last row being "Add reminder".
// on the first row, whatever that is. val reminders = form.reminders
EditCard( val rows = reminders.size + 1
icon = Icons.Default.Notifications, reminders.forEachIndexed { index, minutes ->
iconContentDescription = stringResource(R.string.event_detail_reminders), GroupedItemCard(
header = { position = positionOf(index, rows),
when (val first = form.reminders.firstOrNull()) { icon = Icons.Default.Notifications,
null -> AddReminderChip(onClick = { showReminderPicker = true }) title = reminderLabel(minutes),
else -> ReminderRow( removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
label = reminderLabel(first), onRemove = { viewModel.removeReminder(minutes) },
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 })
}
} }
AddActionCard(
label = stringResource(R.string.event_edit_add_reminder),
position = positionOf(reminders.size, rows),
onClick = { showReminderPicker = true },
)
} }
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) { 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()) { OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
TextButton( TextButton(
@@ -840,6 +909,7 @@ private fun EventEditContent(
) )
} }
if (showRecurrencePicker) { if (showRecurrencePicker) {
RecurrencePickerDialog( RecurrencePickerDialog(
current = form.rrule, current = form.rrule,
@@ -1262,45 +1332,253 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
} }
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */ /** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */
@Composable private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape =
private fun ReminderRow(label: String, onRemove: () -> Unit) { when (position) {
Row( Position.Alone -> RoundedCornerShape(full)
verticalAlignment = Alignment.CenterVertically, Position.Top -> RoundedCornerShape(
modifier = Modifier.fillMaxWidth(), topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
) {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
) )
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( Icon(
imageVector = Icons.Default.Close, imageVector = icon,
contentDescription = stringResource(R.string.event_edit_remove_reminder), contentDescription = null,
modifier = Modifier.size(18.dp), 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 @Composable
private fun AddReminderChip(onClick: () -> Unit) { private fun AddGuestInlineCard(
AssistChip( position: Position,
onClick = onClick, onAdd: (String) -> Unit,
label = { Text(stringResource(R.string.event_edit_add)) }, onPickContact: () -> Unit,
leadingIcon = { ) {
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( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.PersonAdd,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
) )
}, Spacer(Modifier.width(16.dp))
border = null, InlineTextField(
modifier = Modifier.fillMaxWidth(), 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) { private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description 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.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
} }
private fun fieldIcon(field: EventFormField): ImageVector = when (field) { 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.Availability -> Icons.Default.EventAvailable
EventFormField.Visibility -> Icons.Default.Lock EventFormField.Visibility -> Icons.Default.Lock
EventFormField.Color -> Icons.Default.Palette 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.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EditSnapshot import de.jeanlucmakiola.calendula.domain.EditSnapshot
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -353,6 +354,33 @@ class EventEditViewModel @Inject constructor(
update { it.copy(reminders = it.reminders - minutes) } 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. */ /** Moving the start drags the end along, preserving the duration. */
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) } fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, 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.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Visibility -> R.string.event_edit_visibility
EventFormField.Color -> R.string.event_edit_color EventFormField.Color -> R.string.event_edit_color
EventFormField.Attendees -> R.string.event_edit_attendees
} }
@Composable @Composable

View File

@@ -77,6 +77,15 @@
<string name="event_edit_add">Hinzufügen</string> <string name="event_edit_add">Hinzufügen</string>
<string name="event_edit_add_reminder">Erinnerung 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_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="event_edit_reminder_custom">Benutzerdefiniert</string>
<string name="reminder_unit_minutes">Minuten</string> <string name="reminder_unit_minutes">Minuten</string>
<string name="reminder_unit_hours">Stunden</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">Add</string>
<string name="event_edit_add_reminder">Add reminder</string> <string name="event_edit_add_reminder">Add reminder</string>
<string name="event_edit_remove_reminder">Remove 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="event_edit_reminder_custom">Custom</string>
<string name="reminder_unit_minutes">minutes</string> <string name="reminder_unit_minutes">minutes</string>
<string name="reminder_unit_hours">hours</string> <string name="reminder_unit_hours">hours</string>

View File

@@ -211,12 +211,55 @@ class EventFormTest {
} }
@Test @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 loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
val fresh = detail( 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) ).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 @Test