From 4ad1c09f8fe6ec638d2cb517fcb2d838092ca441 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:00:34 +0200 Subject: [PATCH 1/6] feat(detail): show attendee email under the name The attendee row only used the email as a fallback when the name was blank, so a guest with both a name and an email never showed the email. Add it as a supporting line (bodySmall / onSurfaceVariant) beneath the name, kept off rows whose headline already falls back to the email. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/detail/EventDetailScreen.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index e19ca18..c3e4d0c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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), From 92ec718b28689d5dd68fd190fa0939079bedc675 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:00:45 +0200 Subject: [PATCH 2/6] docs(roadmap): sync state and promote attendee editing to Tier 5 Bring the planning docs up to date with reality from the changelog and recent merges: - Mark the .ics engine (export + import) shipped in v2.7.0 and local- calendar backup done; clear the stale "in progress" tags. - Note snooze/dismiss merged into release/v2.8.0 alongside Codeberg crash reports. - Record drag-and-drop rescheduling as consciously rejected. - Promote attendee editing out of the gated bucket into Tier 5 #12 (read side already shipped in v0.6; only the write side is missing), with the sync-adapter invitation caveat to resolve first. - Refresh STATE.md (was stuck at v2.4) through v2.7.5 and the 2.8.0 integration branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 110 ++++++++++++++++++++++++++++++------------- .planning/STATE.md | 76 ++++++++++++++++++++++++------ 2 files changed, 137 insertions(+), 49 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index f194096..e2b0288 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 default reminder **+ per-calendar override**, bundled with battery-exemption 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`), - four surfaces: single-event share + whole-calendar backup (export), - open-`.ics`→form + whole-calendar restore (import). Closes the - device-local-calendar data-loss gap (#10/#11 merged here). Built as **two - sequential branches in one release**: `feat/ics-export` (write side + - UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup). - Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / - `RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`, - non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored - `EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on - import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`. -11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the - `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze - must re-fire an alarm). -12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog) + four surfaces all shipped: single-event share + whole-calendar backup + (export), open-`.ics`→form + whole-calendar bulk import (import). Closed the + device-local-calendar data-loss gap (#10/#11 merged here). Built as two + sequential branches: `feat/ics-export` (write side + UID-on-create precursor) + then `feat/ics-import` (parser, restore, dedup by UID). Import is + liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID` + / guest lists it can't model. Plans: + `docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`. +11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)* — + followed the `.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. **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)** - 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 **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)** -should lead Tier 4 outright (it's a silent data-loss risk, not a feature); -whether drag-drop (#12) jumps ahead given its daily-driver impact. +Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics` +export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected). +**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 @@ -288,8 +302,6 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact. ## 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) - **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form) *(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. - 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)* -- **Local-calendar backup / export** *(Tier 4 #10)* — device-only - (`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no - sync and therefore no backup**: a lost/wiped phone destroys them permanently. - Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus - restore-on-import that recreates events into a chosen local calendar. Reuses - the .ics serializer from the single-event share work; the restore path reuses - the import parser. A data-integrity obligation, not a feature. +- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0** — + device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no + backup. Settings → Calendars → Export writes every event to a user-chosen + `.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by + UID). Closed the silent data-loss gap. ## 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 -- Share event as .ics + open/receive .ics into a prefilled create form - (front-runs the import below) -- ICS file import (drag-and-drop) *(was v3.0, optional)* +- ~~Share event as .ics + open/receive .ics into a prefilled create form~~ + **shipped v2.7.0** — single-event share from detail; opening an `.ics` with one + 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 @@ -455,9 +467,37 @@ in detail yet: trade-off is an explicit go/no-go decision before any work starts. - **Inline contact suggestions** while typing (needs READ_CONTACTS) — only if the picker proves clunky. -- **Attendee editing / invites from contacts** — own milestone; writing - `Attendees` rows touches sync-adapter invitation behavior (Google vs - DAVx5 differ). +- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now + Tier 5 #12, high-importance; the no-permission typed-email path is not an + 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 (new):** add `attendees` to `EventForm` and an attendees section + to `EventEditScreen` — add a guest by typed email (+ optional display name), + remove rows, set role (required / optional). Persist by diffing against the + provider's `CalendarContract.Attendees` rows on insert + dirty-checked update, + mirroring the reminders-diff (kept rows keep their fields). Organizer/self rows + are not user-editable. +- **No new permission for the typed-email path** — writing `Attendees` needs only + the existing `WRITE_CALENDAR`. A contact-picker entry (`ACTION_PICK` on emails, + one-shot, no READ_CONTACTS) can follow as a convenience, reusing the address- + picker mechanism above. +- **Sync-adapter caveat — decide before building:** writing `Attendees` rows + triggers backend-specific *invitation* behavior. Google's provider may auto-mail + invites; CalDAV/DAVx5 writes the `ATTENDEE` property and leaves delivery to the + server. Calendula sends nothing itself (no INTERNET), so what actually reaches a + guest depends entirely on the sync adapter. Settle and document the honest + promise (and any "your account may email this guest" copy) up front. +- **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 @@ -465,3 +505,5 @@ in detail yet: - Natural-language quick entry (high effort, locale-fragile; the prefilled form already covers fast entry) - 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. diff --git a/.planning/STATE.md b/.planning/STATE.md index 28e7971..cbae6fa 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,17 +1,27 @@ # Calendula — Current State -*Last updated: 2026-06-17* +*Last updated: 2026-06-22* ## Status -**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.1 backlog work. v2.2.0 (tap-to-create in day/week + local -calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings, -the calendar manager and the navigation drawer) both shipped 2026-06-16; -v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen -agenda + month widgets, and a "New event" launcher shortcut) shipped -2026-06-17. The backlog is now organised by theme in `ROADMAP.md`. +**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11. +**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`). +**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/ +interop) arc is now done or in flight: +- v2.4.0 per-event colors (2026-06-17) +- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New + event" launcher shortcut (2026-06-17) +- 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.1–v2.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 @@ -120,11 +130,47 @@ agenda + month widgets, and a "New event" launcher shortcut) shipped sync" warning on the picker and in Settings. Color writes flow through 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.1–v2.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 -1. Monitor the F-Droid build/publish for the v2.4.0 tag -2. Decide the "Locations & People" and "remote calendar create/edit" - go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md` -3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then - agenda view (strategic, backs a future widget). Full ranked sequence in - `ROADMAP.md` → "Near-term sequence". +1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the + merge-driven pipeline) once on-device review signs off +2. **Attendee editing** — the committed next feature (Tier 5 #12, high- + importance, opened 2026-06-22). Attendees are already read & shown on the + detail screen since v0.6; the gap is the write side — make guests editable + in `EventEditScreen` / `EventForm` (add by typed email, role, remove), + persisted by diffing `CalendarContract.Attendees`. No new permission for the + typed-email path. **Resolve first:** the backend-specific invitation/sync + behavior (Google auto-mails invites, CalDAV writes `ATTENDEE`) — decide and + document the honest promise. 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**. From ab8a9e34016c14b23eba010e23e8dde5b340e2b6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:05:11 +0200 Subject: [PATCH 3/6] docs(roadmap): settle attendee-invitation behavior (record-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision (owner, 2026-06-22): attendee editing is record-only on any writable calendar. Calendula has no INTERNET and never sends an invitation itself — it only writes Attendees rows. Notification is decided downstream (local: no one; CalDAV: server iMIP; Google: Google). Mandatory backend-aware copy makes this honest. No fabricated ORGANIZER; the optional "send .ics via email app" delegate is deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 23 +++++++++++++++++------ .planning/STATE.md | 7 ++++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e2b0288..225368d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -490,12 +490,23 @@ detail screen (since v0.6) but the form can't write them. Make guests editable. the existing `WRITE_CALENDAR`. A contact-picker entry (`ACTION_PICK` on emails, one-shot, no READ_CONTACTS) can follow as a convenience, reusing the address- picker mechanism above. -- **Sync-adapter caveat — decide before building:** writing `Attendees` rows - triggers backend-specific *invitation* behavior. Google's provider may auto-mail - invites; CalDAV/DAVx5 writes the `ATTENDEE` property and leaves delivery to the - server. Calendula sends nothing itself (no INTERNET), so what actually reaches a - guest depends entirely on the sync adapter. Settle and document the honest - promise (and any "your account may email this guest" copy) up front. +- **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. diff --git a/.planning/STATE.md b/.planning/STATE.md index cbae6fa..a2c9e67 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -167,9 +167,10 @@ release-cut. detail screen since v0.6; the gap is the write side — make guests editable in `EventEditScreen` / `EventForm` (add by typed email, role, remove), persisted by diffing `CalendarContract.Attendees`. No new permission for the - typed-email path. **Resolve first:** the backend-specific invitation/sync - behavior (Google auto-mails invites, CalDAV writes `ATTENDEE`) — decide and - document the honest promise. Full sketch in `ROADMAP.md` → "Attendee editing". + 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 From b0f34ff18a019851104ff08affd5d4fc27461a79 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:42:37 +0200 Subject: [PATCH 4/6] feat(edit): make event attendees editable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attendees were read-only (shown on the detail screen since v0.6) but the form couldn't write them — the last read-only gap in the event model. Add a Guests section to the create/edit form: - Inline grouped list: each guest is a tonal card (avatar, name/email, a tappable Required/Optional role chip, remove); the trailing card is an inline email field — type an address, press Done, it commits. No dialog, matching the app's inline-field input idiom. Manual adds are email-only (names come from sync); the InlineTextField gains IME-action support. - Reminders restyled to the same grouped-list pattern (shared GroupedItemCard / AddActionCard), replacing the single-card blob. Persistence (CalendarDataSource): new guests are written as plain RELATIONSHIP_ATTENDEE / STATUS_INVITED rows — no fabricated organizer. On edit, a dirty-checked reconcileAttendees diffs by email: drops removed guests, inserts new ones, updates only the required/optional flag on kept rows (preserving response status). Organizer, resources and no-email rows are never touched. toEditForm carries only editable guests, so attendees now ride in the edit snapshot and an external guest change trips the conflict check. Per the settled invitation decision: Calendula has no INTERNET and never sends an invitation — it only writes the rows; the backend decides delivery. The section shows honest, calendar-aware copy ("your account may email guests when it syncs" on synced calendars, "no one is notified" on local). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarDataSource.kt | 141 ++++++++ .../calendula/domain/EventForm.kt | 48 ++- .../calendula/ui/common/InlineTextField.kt | 9 + .../calendula/ui/edit/EventEditScreen.kt | 325 +++++++++++++++--- .../calendula/ui/edit/EventEditViewModel.kt | 28 ++ .../calendula/ui/settings/SettingsScreen.kt | 1 + app/src/main/res/values-de/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + .../calendula/domain/EventFormTest.kt | 49 ++- 9 files changed, 557 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 44c25c3..2d958e9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm @@ -536,6 +537,10 @@ class AndroidCalendarDataSource @Inject constructor( Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId") } } + // Guests are best-effort like reminders: a row that fails to attach is + // logged, not surfaced as a failed create. Calendula never sends an + // invitation — it only writes the rows; the backend decides delivery. + insertAttendees(eventId, form.attendees) return eventId } @@ -565,6 +570,12 @@ class AndroidCalendarDataSource @Inject constructor( if (updated.reminders.toSet() != original.reminders.toSet()) { reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes)) } + // Same untouched-set guard for guests: only reconcile when the form's + // attendees actually changed, so unrelated edits never disturb the + // organizer/resource rows the form doesn't model. + if (updated.attendees.toSet() != original.attendees.toSet()) { + reconcileAttendees(eventId, updated.attendees) + } } override fun updateOccurrence( @@ -587,6 +598,7 @@ class AndroidCalendarDataSource @Inject constructor( // Whether the provider copied the parent's reminder rows is its // business — reconciling against the actual rows handles both ways. reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes)) + reconcileAttendees(exceptionId, form.attendees) return exceptionId } @@ -720,6 +732,135 @@ class AndroidCalendarDataSource @Inject constructor( } } + /** Normalised key an attendee is matched/deduped on (case-insensitive email). */ + private fun EventAttendee.key(): String = email.trim().lowercase() + + /** Distinct guests by email; blanks dropped (we key and write on the address). */ + private fun List.distinctByEmail(): List { + val seen = HashSet() + return filter { it.email.isNotBlank() && seen.add(it.key()) } + } + + private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues = + ContentValues().apply { + put(CalendarContract.Attendees.EVENT_ID, eventId) + put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim()) + attendee.name.trim().takeIf { it.isNotEmpty() } + ?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) } + // A plain guest — never the organizer (we don't fabricate scheduling + // identity; the backend owns that). Invited = awaiting their reply. + put( + CalendarContract.Attendees.ATTENDEE_RELATIONSHIP, + CalendarContract.Attendees.RELATIONSHIP_ATTENDEE, + ) + put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType()) + put( + CalendarContract.Attendees.ATTENDEE_STATUS, + CalendarContract.Attendees.ATTENDEE_STATUS_INVITED, + ) + } + + private fun EventAttendee.providerType(): Int = if (optional) { + CalendarContract.Attendees.TYPE_OPTIONAL + } else { + CalendarContract.Attendees.TYPE_REQUIRED + } + + /** Best-effort attendee inserts (like reminders); emails are never logged. */ + private fun insertAttendees(eventId: Long, attendees: List) { + attendees.distinctByEmail().forEach { attendee -> + if (resolver.insert( + CalendarContract.Attendees.CONTENT_URI, + attendeeValues(eventId, attendee), + ) == null + ) { + Log.w(TAG, "Failed to attach a guest to event $eventId") + } + } + } + + private data class AttendeeRow( + val id: Long, + val email: String, + val relationship: Int, + val type: Int, + ) { + val key: String get() = email.trim().lowercase() + } + + private fun queryAttendeeRows(eventId: Long): List = resolver.query( + CalendarContract.Attendees.CONTENT_URI, + arrayOf( + CalendarContract.Attendees._ID, + CalendarContract.Attendees.ATTENDEE_EMAIL, + CalendarContract.Attendees.ATTENDEE_RELATIONSHIP, + CalendarContract.Attendees.ATTENDEE_TYPE, + ), + CalendarContract.Attendees.EVENT_ID + " = ?", + arrayOf(eventId.toString()), + null, + )?.use { c -> + buildList { + while (c.moveToNext()) { + add( + AttendeeRow( + id = c.getLong(0), + email = c.getString(1).orEmpty(), + relationship = c.getInt(2), + type = c.getInt(3), + ), + ) + } + } + } ?: emptyList() + + /** + * Make the event's *editable* guest rows match [attendees]. Only rows we + * own — has an email, not the organizer, not a resource — are reconciled; + * everything else the backend put there is left untouched. Matched rows keep + * their response status (only the required/optional type is updated if it + * changed); dropped rows are deleted; new guests inserted. Calendula sends + * nothing — the backend decides if anyone is notified on sync. + */ + private fun reconcileAttendees(eventId: Long, attendees: List) { + val target = attendees.distinctByEmail() + val targetByKey = target.associateBy { it.key() } + val editable = queryAttendeeRows(eventId).filter { + it.email.isNotBlank() && + it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER && + it.type != CalendarContract.Attendees.TYPE_RESOURCE + } + val editableKeys = editable.map { it.key }.toSet() + editable.forEach { row -> + val want = targetByKey[row.key] + if (want == null) { + resolver.delete( + CalendarContract.Attendees.CONTENT_URI, + CalendarContract.Attendees._ID + " = ?", + arrayOf(row.id.toString()), + ) + } else if (row.type != want.providerType()) { + resolver.update( + CalendarContract.Attendees.CONTENT_URI, + ContentValues().apply { + put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType()) + }, + CalendarContract.Attendees._ID + " = ?", + arrayOf(row.id.toString()), + ) + } + } + target.filterNot { it.key() in editableKeys }.forEach { attendee -> + if (resolver.insert( + CalendarContract.Attendees.CONTENT_URI, + attendeeValues(eventId, attendee), + ) == null + ) { + Log.w(TAG, "Failed to attach a guest to event $eventId") + } + } + } + private fun Map.toContentValues(): ContentValues = ContentValues().also { cv -> forEach { (column, value) -> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 3cfdc40..5acc612 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -41,6 +41,27 @@ data class EventForm( */ val colorKey: String? = null, val color: Int? = null, + /** + * Guests the user has added/kept on the event. Calendula only writes these + * `Attendees` rows — it has no INTERNET and never sends an invitation + * itself; whether a guest is notified is decided downstream by the + * calendar's backend (local: no one; CalDAV/Google: the server/account). + * Read-only rows the form doesn't model (the organizer, resources) are + * preserved by the data layer, not carried here. + */ + val attendees: List = emptyList(), +) + +/** + * One editable guest: the user controls the [email] (the identity we dedup and + * write on), an optional display [name], and whether they're [optional] rather + * than required. Response status and the organizer/resource distinction are not + * user-editable, so they aren't modelled here. + */ +data class EventAttendee( + val email: String, + val name: String = "", + val optional: Boolean = false, ) /** @@ -55,6 +76,7 @@ enum class EventFormField { Availability, Visibility, Color, + Attendees, } enum class EventFormProblem { @@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): // calendar's colour. colorKey = eventColorKey, color = eventColor, + // Only editable guests ride in the form: drop the organizer and + // resource rows (not user-editable) and any without an email (we key + // edits and dedup on the address). The data layer preserves the rows we + // don't carry here, so they're never clobbered on save. + attendees = attendees + .filter { + it.relationship != AttendeeRelationship.Organizer && + it.type != AttendeeType.Resource + } + .mapNotNull { a -> + a.email?.takeIf { it.isNotBlank() }?.let { email -> + EventAttendee( + email = email, + name = a.name, + optional = a.type == AttendeeType.Optional, + ) + } + }, ) } @@ -117,9 +157,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): * while the form was open. The raw row times ride along because * [toEditForm] derives the form's times from the *tapped occurrence*, so * re-deriving with the same occurrence would mask an externally moved - * event. Not covered (the form can't write them, and the dirty-checked - * write can't clobber them): attendees, status, the user's own response, - * reminder methods, and a recurring event's duration. + * event. Guests are covered (the form writes editable attendees), so an + * external attendee change now also trips the conflict check. Still not + * covered: status, the user's own response, reminder methods, the + * organizer/resource rows, and a recurring event's duration. */ data class EditSnapshot( val form: EventForm, @@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set = buildSet { if (availability != Availability.Busy) add(EventFormField.Availability) if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility) if (colorKey != null || color != null) add(EventFormField.Color) + if (attendees.isNotEmpty()) add(EventFormField.Attendees) } fun EventForm.problems(): Set = buildSet { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt index 85105e0..6a541f1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/InlineTextField.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -12,6 +13,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -36,6 +38,9 @@ fun InlineTextField( minLines: Int = 1, keyboardType: KeyboardType = KeyboardType.Text, capitalization: KeyboardCapitalization = KeyboardCapitalization.None, + imeAction: ImeAction = ImeAction.Default, + /** Invoked when the IME action key (e.g. Done) is pressed. */ + onImeAction: (() -> Unit)? = null, ) { val resolvedStyle = textStyle.copy( color = if (textStyle.color.isSpecified) { @@ -53,7 +58,11 @@ fun InlineTextField( keyboardOptions = KeyboardOptions( keyboardType = keyboardType, capitalization = capitalization, + imeAction = imeAction, ), + keyboardActions = onImeAction?.let { action -> + KeyboardActions(onAny = { action() }) + } ?: KeyboardActions.Default, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), decorationBox = { innerTextField -> Box { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 3f9866e..a762c2b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.edit import android.Manifest import android.content.pm.PackageManager +import android.util.Patterns import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -30,7 +31,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Notes @@ -42,6 +42,9 @@ import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat @@ -49,11 +52,11 @@ import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -82,13 +85,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -97,6 +104,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField @@ -115,6 +123,8 @@ import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.OptionCard +import de.jeanlucmakiola.calendula.ui.common.Position +import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.ReminderUnit import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert @@ -587,33 +597,24 @@ private fun EventEditContent( OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) { Spacer(Modifier.height(gap)) - // Reminders stack one per row (remove on the right edge); the - // add chip closes the card at the bottom. The card icon centres - // on the first row, whatever that is. - EditCard( - icon = Icons.Default.Notifications, - iconContentDescription = stringResource(R.string.event_detail_reminders), - header = { - when (val first = form.reminders.firstOrNull()) { - null -> AddReminderChip(onClick = { showReminderPicker = true }) - else -> ReminderRow( - label = reminderLabel(first), - onRemove = { viewModel.removeReminder(first) }, - ) - } - }, - ) { - form.reminders.drop(1).forEach { minutes -> - ReminderRow( - label = reminderLabel(minutes), - onRemove = { viewModel.removeReminder(minutes) }, - ) - } - if (form.reminders.isNotEmpty()) { - Spacer(Modifier.height(4.dp)) - AddReminderChip(onClick = { showReminderPicker = true }) - } + // A grouped list like guests: each reminder is its own tonal card + // with connected corners, the last row being "Add reminder". + val reminders = form.reminders + val rows = reminders.size + 1 + reminders.forEachIndexed { index, minutes -> + GroupedItemCard( + position = positionOf(index, rows), + icon = Icons.Default.Notifications, + title = reminderLabel(minutes), + removeContentDescription = stringResource(R.string.event_edit_remove_reminder), + onRemove = { viewModel.removeReminder(minutes) }, + ) } + AddActionCard( + label = stringResource(R.string.event_edit_add_reminder), + position = positionOf(reminders.size, rows), + onClick = { showReminderPicker = true }, + ) } OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) { @@ -776,6 +777,42 @@ private fun EventEditContent( } } + OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) { + Spacer(Modifier.height(gap)) + // Guests are an inline grouped list: each is a tonal card with a + // role toggle + remove, and the trailing card is an inline email + // field — type an address, press Done, it commits. Below sits an + // honest note — Calendula never sends invitations, so whether a + // guest is notified is the calendar backend's call. + val guests = form.attendees + val rows = guests.size + 1 + guests.forEachIndexed { index, attendee -> + GuestCard( + attendee = attendee, + position = positionOf(index, rows), + onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) }, + onRemove = { viewModel.removeAttendee(attendee) }, + ) + } + AddGuestInlineCard( + position = positionOf(guests.size, rows), + onAdd = { viewModel.addAttendee(it) }, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + if (isLocalCalendar) { + R.string.event_edit_attendees_note_local + } else { + R.string.event_edit_attendees_note_synced + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { Spacer(Modifier.height(20.dp)) TextButton( @@ -840,6 +877,7 @@ private fun EventEditContent( ) } + if (showRecurrencePicker) { RecurrencePickerDialog( current = form.rrule, @@ -1262,45 +1300,222 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) { RecurrenceFreq.Yearly -> R.string.recurrence_unit_years } -/** One chosen reminder: humanised lead time, remove pinned to the right edge. */ -@Composable -private fun ReminderRow(label: String, onRemove: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), +/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */ +private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape = + when (position) { + Position.Alone -> RoundedCornerShape(full) + Position.Top -> RoundedCornerShape( + topStart = full, topEnd = full, bottomStart = small, bottomEnd = small, ) - IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Position.Middle -> RoundedCornerShape(small) + Position.Bottom -> RoundedCornerShape( + topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, + ) + } + +/** The 2dp gap that visually separates grouped cards (none after the last). */ +private fun groupedGap(position: Position): Modifier = when (position) { + Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) + Position.Bottom, Position.Alone -> Modifier +} + +/** + * One entry in a grouped form list (a guest, a reminder): a leading [icon], + * a [title] with optional [supporting] line, and a remove button — all on a + * tonal card whose corners come from its [position] in the run. + */ +@Composable +private fun GroupedItemCard( + position: Position, + icon: ImageVector, + title: String, + removeContentDescription: String, + onRemove: () -> Unit, + supporting: String? = null, + titleMaxLines: Int = Int.MAX_VALUE, + trailing: @Composable (() -> Unit)? = null, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + ) { Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.event_edit_remove_reminder), - modifier = Modifier.size(18.dp), + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + ) + if (supporting != null) { + Text( + text = supporting, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (trailing != null) { + Spacer(Modifier.width(8.dp)) + trailing() + } + IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = removeContentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +/** + * One guest as a grouped-list card: avatar glyph, name/email, a tappable + * Required/Optional role chip, and remove. The email shows as the supporting + * line when a name is present, otherwise as the title. + */ +@Composable +private fun GuestCard( + attendee: EventAttendee, + position: Position, + onToggleOptional: (Boolean) -> Unit, + onRemove: () -> Unit, +) { + val hasName = attendee.name.isNotBlank() + GroupedItemCard( + position = position, + icon = Icons.Default.Person, + title = if (hasName) attendee.name else attendee.email, + supporting = attendee.email.takeIf { hasName }, + titleMaxLines = 1, + removeContentDescription = stringResource(R.string.event_edit_remove_guest), + onRemove = onRemove, + trailing = { + FilterChip( + selected = attendee.optional, + onClick = { onToggleOptional(!attendee.optional) }, + label = { + Text( + stringResource( + if (attendee.optional) { + R.string.event_edit_attendee_optional + } else { + R.string.event_edit_attendee_required + }, + ), + ) + }, + ) + }, + ) +} + +/** The trailing "Add …" action card of a grouped form list (opens a picker). */ +@Composable +private fun AddActionCard(label: String, position: Position, onClick: () -> Unit) { + Surface( + onClick = onClick, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, ) } } } +/** + * The trailing card of the guests list: an inline email field. Type an address + * and press Done (or tap the add button) to commit it as a guest; the field + * clears, ready for the next. Matches the form's inline-field input style — no + * dialog. + */ @Composable -private fun AddReminderChip(onClick: () -> Unit) { - AssistChip( - onClick = onClick, - label = { Text(stringResource(R.string.event_edit_add)) }, - leadingIcon = { +private fun AddGuestInlineCard(position: Position, onAdd: (String) -> Unit) { + var text by rememberSaveable { mutableStateOf("") } + val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() } + fun commit() { + if (valid) { + onAdd(text.trim()) + text = "" + } + } + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + ) { Icon( - imageVector = Icons.Default.Add, + imageVector = Icons.Default.PersonAdd, contentDescription = null, - modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), ) - }, - border = null, - modifier = Modifier.fillMaxWidth(), - ) + Spacer(Modifier.width(16.dp)) + InlineTextField( + value = text, + onValueChange = { text = it }, + placeholder = stringResource(R.string.event_edit_add_guest_hint), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + onImeAction = { commit() }, + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + ) + if (valid) { + IconButton(onClick = { commit() }, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.event_edit_add_guest), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } + } + } + } } + private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location EventFormField.Description -> R.string.event_detail_description @@ -1309,6 +1524,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Color -> R.string.event_edit_color + EventFormField.Attendees -> R.string.event_edit_attendees } private fun fieldIcon(field: EventFormField): ImageVector = when (field) { @@ -1319,6 +1535,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Availability -> Icons.Default.EventAvailable EventFormField.Visibility -> Icons.Default.Lock EventFormField.Color -> Icons.Default.Palette + EventFormField.Attendees -> Icons.Default.People } /** diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index d30ece4..e878330 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EditSnapshot +import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField @@ -353,6 +354,33 @@ class EventEditViewModel @Inject constructor( update { it.copy(reminders = it.reminders - minutes) } } + /** + * Add a guest, keyed on a trimmed lower-cased email so re-adding the same + * address just refreshes it (no duplicate row). A blank email is ignored — + * the email is what we write and dedup on. + */ + fun addAttendee(email: String, name: String = "", optional: Boolean = false) { + val trimmed = email.trim() + if (trimmed.isEmpty()) return + update { form -> + val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional) + val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) } + form.copy(attendees = rest + attendee) + } + } + + fun removeAttendee(attendee: EventAttendee) = + update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) } + + /** Flip a guest between required and optional (the per-row role toggle). */ + fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form -> + form.copy( + attendees = form.attendees.map { a -> + if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a + }, + ) + } + /** Moving the start drags the end along, preserving the duration. */ fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) } fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 4d1a06f..ced7652 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -872,6 +872,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Color -> R.string.event_edit_color + EventFormField.Attendees -> R.string.event_edit_attendees } @Composable diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4ec4c11..af76e2e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -77,6 +77,14 @@ Hinzufügen Erinnerung hinzufügen Erinnerung entfernen + Gäste + Gast hinzufügen + Gast per E-Mail hinzufügen… + Gast entfernen + Erforderlich + Optional + Calendula versendet keine Einladungen. Dein Kalenderkonto sendet Gästen bei der Synchronisierung unter Umständen eine E-Mail. + Auf diesem Gerät gespeichert. Niemand wird benachrichtigt. Benutzerdefiniert Minuten Stunden diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7801091..301596b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -78,6 +78,14 @@ Add Add reminder Remove reminder + Guests + Add guest + Add a guest by email… + Remove guest + Required + Optional + Calendula doesn\'t send invitations. Your calendar account may email guests when it syncs. + Stored on this device. No one is notified. Custom minutes hours diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index c6a9066..fc975c6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -211,12 +211,55 @@ class EventFormTest { } @Test - fun `changes the form cannot write do not fake a conflict`() { + fun `a reminder-method change the form cannot write does not fake a conflict`() { + // The form carries reminder minutes only, not the method — so a method + // flip the form can't express must not read as an external change. + val loaded = detail(reminders = listOf(Reminder(10, ReminderMethod.Alert))) + .toEditSnapshot(0L, 3_600_000L, berlin) + val fresh = detail(reminders = listOf(Reminder(10, ReminderMethod.Email))) + .toEditSnapshot(0L, 3_600_000L, berlin) + assertThat(fresh).isEqualTo(loaded) + } + + @Test + fun `an external guest change now trips the conflict check`() { + // Attendees are writable now, so they ride in the snapshot: adding a + // guest externally is a real conflict, not invisible. val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin) val fresh = detail( - attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)), + attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction)), ).toEditSnapshot(0L, 3_600_000L, berlin) - assertThat(fresh).isEqualTo(loaded) + assertThat(fresh).isNotEqualTo(loaded) + } + + @Test + fun `toEditForm carries editable guests with name email and optional role`() { + val prefilled = detail( + attendees = listOf( + Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted, type = AttendeeType.Required), + Attendee("", "bob@example.org", AttendeeStatus.NeedsAction, type = AttendeeType.Optional), + ), + ).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin) + assertThat(prefilled.attendees).containsExactly( + EventAttendee(email = "ada@example.org", name = "Ada", optional = false), + EventAttendee(email = "bob@example.org", name = "", optional = true), + ).inOrder() + assertThat(prefilled.populatedFields()).contains(EventFormField.Attendees) + } + + @Test + fun `toEditForm drops the organizer, resources and guests without an email`() { + val prefilled = detail( + attendees = listOf( + Attendee("Org", "org@example.org", AttendeeStatus.Accepted, AttendeeRelationship.Organizer), + Attendee("Room A", "room@example.org", AttendeeStatus.Accepted, type = AttendeeType.Resource), + Attendee("No address", null, AttendeeStatus.NeedsAction), + Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction), + ), + ).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin) + assertThat(prefilled.attendees).containsExactly( + EventAttendee(email = "ada@example.org", name = "Ada", optional = false), + ) } @Test From ac17343747c58c23c4e932a3ff8a5b3b063b2b31 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:44:51 +0200 Subject: [PATCH 5/6] docs(roadmap): attendee write-side shipped; contact picker is the name path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark the attendee-editing write side shipped (b0f34ff) and record the plan to close the name-on-manual-add gap: a "from contacts" entry via ACTION_PICK on the contacts Email URI, querying the picked row for Email.ADDRESS + DISPLAY_NAME — no READ_CONTACTS (the result Intent grants temporary read access), same no-permission mechanism as the location address picker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 225368d..983411d 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -480,16 +480,25 @@ detail screen (since v0.6) but the form can't write them. Make guests editable. enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add there. -- **Write side (new):** add `attendees` to `EventForm` and an attendees section - to `EventEditScreen` — add a guest by typed email (+ optional display name), - remove rows, set role (required / optional). Persist by diffing against the - provider's `CalendarContract.Attendees` rows on insert + dirty-checked update, - mirroring the reminders-diff (kept rows keep their fields). Organizer/self rows - are not user-editable. -- **No new permission for the typed-email path** — writing `Attendees` needs only - the existing `WRITE_CALENDAR`. A contact-picker entry (`ACTION_PICK` on emails, - one-shot, no READ_CONTACTS) can follow as a convenience, reusing the address- - picker mechanism above. +- **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 — solved next via a contact picker.** The inline field is + email-only (the quick path); names otherwise come from sync. The planned fix is + a **"from contacts" entry**: `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 directly. 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: From a4e0ec3fde805415e020275b03c982c445d5faf7 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 22 Jun 2026 23:50:23 +0200 Subject: [PATCH 6/6] feat(edit): add guests from the contact picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the name-on-manual-add gap. The add-guest row gains a "from contacts" button that launches the system contact picker (ACTION_PICK on ContactsContract Email URI); the picked email row is queried for Email.ADDRESS + DISPLAY_NAME, so a chosen guest gets both email and name in one tap. No READ_CONTACTS — the result Intent grants temporary read access to just that row, so Calendula stays no-permission/no-network. The inline email field remains the quick email-only path. Mark the roadmap item shipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 18 ++--- .../calendula/ui/edit/EventEditScreen.kt | 73 +++++++++++++++++-- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 983411d..e090596 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -490,15 +490,15 @@ detail screen (since v0.6) but the form can't write them. Make guests editable. required/optional type updates; organizer/resource/no-email rows untouched). Reminders restyled to the same grouped-list pattern in passing. Needs only the existing `WRITE_CALENDAR` — no new permission. -- **Name on manual add — solved next via a contact picker.** The inline field is - email-only (the quick path); names otherwise come from sync. The planned fix is - a **"from contacts" entry**: `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 directly. 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. +- **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: diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index a762c2b..1e9416d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -1,7 +1,12 @@ 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 @@ -38,6 +43,7 @@ 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 @@ -439,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. @@ -797,6 +819,16 @@ private fun EventEditContent( 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( @@ -1457,13 +1489,17 @@ private fun AddActionCard(label: String, position: Position, onClick: () -> Unit } /** - * The trailing card of the guests list: an inline email field. Type an address - * and press Done (or tap the add button) to commit it as a guest; the field - * clears, ready for the next. Matches the form's inline-field input style — no - * dialog. + * 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 AddGuestInlineCard(position: Position, onAdd: (String) -> Unit) { +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() { @@ -1511,10 +1547,37 @@ private fun AddGuestInlineCard(position: Position, onAdd: (String) -> Unit) { ) } } + IconButton(onClick = onPickContact, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Contacts, + contentDescription = stringResource(R.string.event_edit_add_guest_from_contacts), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } } } } +/** + * Read the email + display name from a contact-pick result URI. No + * READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access. + */ +private fun readContactEmail(context: Context, uri: Uri): Pair? = + 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 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index af76e2e..712bab9 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -80,6 +80,7 @@ Gäste Gast hinzufügen Gast per E-Mail hinzufügen… + Aus Kontakten hinzufügen Gast entfernen Erforderlich Optional diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 301596b..3ee560c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,7 @@ Guests Add guest Add a guest by email… + Add from contacts Remove guest Required Optional