diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index f194096..e090596 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,57 @@ 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 — 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 @@ -465,3 +525,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..a2c9e67 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,48 @@ 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. **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**. 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/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), 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..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,13 @@ package de.jeanlucmakiola.calendula.ui.edit import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri +import android.provider.ContactsContract +import android.util.Patterns import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -30,7 +36,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Notes @@ -38,10 +43,14 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Contacts import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat @@ -49,11 +58,11 @@ import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -82,13 +91,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel @@ -97,6 +110,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventAttendee import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventFormField @@ -115,6 +129,8 @@ import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.OptionCard +import de.jeanlucmakiola.calendula.ui.common.Position +import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.ReminderUnit import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert @@ -429,6 +445,22 @@ private fun EventEditContent( var showColorPicker by rememberSaveable { mutableStateOf(false) } var showFieldPicker by rememberSaveable { mutableStateOf(false) } + // Pick a guest from contacts: a one-shot system picker returning the chosen + // email row (email + display name). No READ_CONTACTS — the result Intent + // grants temporary read access, so Calendula stays no-network/no-permission. + val context = LocalContext.current + val pickContact = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + result.data?.data?.let { uri -> + readContactEmail(context, uri)?.let { (email, name) -> + viewModel.addAttendee(email, name) + } + } + } + } + val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } // The accent ties the form to the detail screen's design language: the // bar under the title takes the target calendar's colour. @@ -587,33 +619,24 @@ private fun EventEditContent( OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) { Spacer(Modifier.height(gap)) - // Reminders stack one per row (remove on the right edge); the - // add chip closes the card at the bottom. The card icon centres - // on the first row, whatever that is. - EditCard( - icon = Icons.Default.Notifications, - iconContentDescription = stringResource(R.string.event_detail_reminders), - header = { - when (val first = form.reminders.firstOrNull()) { - null -> AddReminderChip(onClick = { showReminderPicker = true }) - else -> ReminderRow( - label = reminderLabel(first), - onRemove = { viewModel.removeReminder(first) }, - ) - } - }, - ) { - form.reminders.drop(1).forEach { minutes -> - ReminderRow( - label = reminderLabel(minutes), - onRemove = { viewModel.removeReminder(minutes) }, - ) - } - if (form.reminders.isNotEmpty()) { - Spacer(Modifier.height(4.dp)) - AddReminderChip(onClick = { showReminderPicker = true }) - } + // A grouped list like guests: each reminder is its own tonal card + // with connected corners, the last row being "Add reminder". + val reminders = form.reminders + val rows = reminders.size + 1 + reminders.forEachIndexed { index, minutes -> + GroupedItemCard( + position = positionOf(index, rows), + icon = Icons.Default.Notifications, + title = reminderLabel(minutes), + removeContentDescription = stringResource(R.string.event_edit_remove_reminder), + onRemove = { viewModel.removeReminder(minutes) }, + ) } + AddActionCard( + label = stringResource(R.string.event_edit_add_reminder), + position = positionOf(reminders.size, rows), + onClick = { showReminderPicker = true }, + ) } OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) { @@ -776,6 +799,52 @@ private fun EventEditContent( } } + OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) { + Spacer(Modifier.height(gap)) + // Guests are an inline grouped list: each is a tonal card with a + // role toggle + remove, and the trailing card is an inline email + // field — type an address, press Done, it commits. Below sits an + // honest note — Calendula never sends invitations, so whether a + // guest is notified is the calendar backend's call. + val guests = form.attendees + val rows = guests.size + 1 + guests.forEachIndexed { index, attendee -> + GuestCard( + attendee = attendee, + position = positionOf(index, rows), + onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) }, + onRemove = { viewModel.removeAttendee(attendee) }, + ) + } + AddGuestInlineCard( + position = positionOf(guests.size, rows), + onAdd = { viewModel.addAttendee(it) }, + onPickContact = { + runCatching { + pickContact.launch( + Intent( + Intent.ACTION_PICK, + ContactsContract.CommonDataKinds.Email.CONTENT_URI, + ), + ) + } + }, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + if (isLocalCalendar) { + R.string.event_edit_attendees_note_local + } else { + R.string.event_edit_attendees_note_synced + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } + OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { Spacer(Modifier.height(20.dp)) TextButton( @@ -840,6 +909,7 @@ private fun EventEditContent( ) } + if (showRecurrencePicker) { RecurrencePickerDialog( current = form.rrule, @@ -1262,45 +1332,253 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) { RecurrenceFreq.Yearly -> R.string.recurrence_unit_years } -/** One chosen reminder: humanised lead time, remove pinned to the right edge. */ -@Composable -private fun ReminderRow(label: String, onRemove: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), +/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */ +private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape = + when (position) { + Position.Alone -> RoundedCornerShape(full) + Position.Top -> RoundedCornerShape( + topStart = full, topEnd = full, bottomStart = small, bottomEnd = small, ) - IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Position.Middle -> RoundedCornerShape(small) + Position.Bottom -> RoundedCornerShape( + topStart = small, topEnd = small, bottomStart = full, bottomEnd = full, + ) + } + +/** The 2dp gap that visually separates grouped cards (none after the last). */ +private fun groupedGap(position: Position): Modifier = when (position) { + Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp) + Position.Bottom, Position.Alone -> Modifier +} + +/** + * One entry in a grouped form list (a guest, a reminder): a leading [icon], + * a [title] with optional [supporting] line, and a remove button — all on a + * tonal card whose corners come from its [position] in the run. + */ +@Composable +private fun GroupedItemCard( + position: Position, + icon: ImageVector, + title: String, + removeContentDescription: String, + onRemove: () -> Unit, + supporting: String? = null, + titleMaxLines: Int = Int.MAX_VALUE, + trailing: @Composable (() -> Unit)? = null, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + ) { Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.event_edit_remove_reminder), - modifier = Modifier.size(18.dp), + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = titleMaxLines, + overflow = TextOverflow.Ellipsis, + ) + if (supporting != null) { + Text( + text = supporting, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (trailing != null) { + Spacer(Modifier.width(8.dp)) + trailing() + } + IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = removeContentDescription, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +/** + * One guest as a grouped-list card: avatar glyph, name/email, a tappable + * Required/Optional role chip, and remove. The email shows as the supporting + * line when a name is present, otherwise as the title. + */ +@Composable +private fun GuestCard( + attendee: EventAttendee, + position: Position, + onToggleOptional: (Boolean) -> Unit, + onRemove: () -> Unit, +) { + val hasName = attendee.name.isNotBlank() + GroupedItemCard( + position = position, + icon = Icons.Default.Person, + title = if (hasName) attendee.name else attendee.email, + supporting = attendee.email.takeIf { hasName }, + titleMaxLines = 1, + removeContentDescription = stringResource(R.string.event_edit_remove_guest), + onRemove = onRemove, + trailing = { + FilterChip( + selected = attendee.optional, + onClick = { onToggleOptional(!attendee.optional) }, + label = { + Text( + stringResource( + if (attendee.optional) { + R.string.event_edit_attendee_optional + } else { + R.string.event_edit_attendee_required + }, + ), + ) + }, + ) + }, + ) +} + +/** The trailing "Add …" action card of a grouped form list (opens a picker). */ +@Composable +private fun AddActionCard(label: String, position: Position, onClick: () -> Unit) { + Surface( + onClick = onClick, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, ) } } } +/** + * The trailing card of the guests list: an inline email field plus a "from + * contacts" button. Type an address and press Done (or tap +) to commit a guest; + * or pick from contacts to fill email + name at once. The field clears after, + * ready for the next. Matches the form's inline-field input style — no dialog. + */ @Composable -private fun AddReminderChip(onClick: () -> Unit) { - AssistChip( - onClick = onClick, - label = { Text(stringResource(R.string.event_edit_add)) }, - leadingIcon = { +private fun AddGuestInlineCard( + position: Position, + onAdd: (String) -> Unit, + onPickContact: () -> Unit, +) { + var text by rememberSaveable { mutableStateOf("") } + val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() } + fun commit() { + if (valid) { + onAdd(text.trim()) + text = "" + } + } + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = groupedShape(position), + modifier = Modifier + .fillMaxWidth() + .then(groupedGap(position)), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + ) { Icon( - imageVector = Icons.Default.Add, + imageVector = Icons.Default.PersonAdd, contentDescription = null, - modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), ) - }, - border = null, - modifier = Modifier.fillMaxWidth(), - ) + Spacer(Modifier.width(16.dp)) + InlineTextField( + value = text, + onValueChange = { text = it }, + placeholder = stringResource(R.string.event_edit_add_guest_hint), + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + onImeAction = { commit() }, + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + ) + if (valid) { + IconButton(onClick = { commit() }, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.event_edit_add_guest), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + } + } + IconButton(onClick = onPickContact, modifier = Modifier.size(40.dp)) { + Icon( + imageVector = Icons.Default.Contacts, + contentDescription = stringResource(R.string.event_edit_add_guest_from_contacts), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } + } } +/** + * Read the email + display name from a contact-pick result URI. No + * READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access. + */ +private fun readContactEmail(context: Context, uri: Uri): Pair? = + context.contentResolver.query( + uri, + arrayOf( + ContactsContract.CommonDataKinds.Email.ADDRESS, + ContactsContract.Contacts.DISPLAY_NAME, + ), + null, null, null, + )?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val email = cursor.getString(0).orEmpty().trim() + val name = cursor.getString(1).orEmpty().trim() + email.takeIf { it.isNotEmpty() }?.let { it to name } + } + + private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Location -> R.string.event_detail_location EventFormField.Description -> R.string.event_detail_description @@ -1309,6 +1587,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) { EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Color -> R.string.event_edit_color + EventFormField.Attendees -> R.string.event_edit_attendees } private fun fieldIcon(field: EventFormField): ImageVector = when (field) { @@ -1319,6 +1598,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) { EventFormField.Availability -> Icons.Default.EventAvailable EventFormField.Visibility -> Icons.Default.Lock EventFormField.Color -> Icons.Default.Palette + EventFormField.Attendees -> Icons.Default.People } /** 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 d5e8f63..604ac85 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 @@ -905,6 +905,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) { EventFormField.Availability -> R.string.event_edit_availability EventFormField.Visibility -> R.string.event_edit_visibility EventFormField.Color -> R.string.event_edit_color + EventFormField.Attendees -> R.string.event_edit_attendees } @Composable diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a6ee216..037c717 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -77,6 +77,15 @@ Hinzufügen Erinnerung hinzufügen Erinnerung entfernen + Gäste + Gast hinzufügen + Gast per E-Mail hinzufügen… + Aus Kontakten 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 302e67e..21e31be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -78,6 +78,15 @@ Add Add reminder Remove reminder + Guests + Add guest + Add a guest by email… + Add from contacts + 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