Merge pull request 'feat: editable event attendees with contact picker' (!29) from feat/attendee-editing into release/v2.8.0
Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -536,6 +537,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
// Guests are best-effort like reminders: a row that fails to attach is
|
||||
// logged, not surfaced as a failed create. Calendula never sends an
|
||||
// invitation — it only writes the rows; the backend decides delivery.
|
||||
insertAttendees(eventId, form.attendees)
|
||||
return eventId
|
||||
}
|
||||
|
||||
@@ -565,6 +570,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
||||
}
|
||||
// Same untouched-set guard for guests: only reconcile when the form's
|
||||
// attendees actually changed, so unrelated edits never disturb the
|
||||
// organizer/resource rows the form doesn't model.
|
||||
if (updated.attendees.toSet() != original.attendees.toSet()) {
|
||||
reconcileAttendees(eventId, updated.attendees)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateOccurrence(
|
||||
@@ -587,6 +598,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
// Whether the provider copied the parent's reminder rows is its
|
||||
// business — reconciling against the actual rows handles both ways.
|
||||
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||
reconcileAttendees(exceptionId, form.attendees)
|
||||
return exceptionId
|
||||
}
|
||||
|
||||
@@ -720,6 +732,135 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
|
||||
private fun EventAttendee.key(): String = email.trim().lowercase()
|
||||
|
||||
/** Distinct guests by email; blanks dropped (we key and write on the address). */
|
||||
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
|
||||
val seen = HashSet<String>()
|
||||
return filter { it.email.isNotBlank() && seen.add(it.key()) }
|
||||
}
|
||||
|
||||
private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues =
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Attendees.EVENT_ID, eventId)
|
||||
put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim())
|
||||
attendee.name.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) }
|
||||
// A plain guest — never the organizer (we don't fabricate scheduling
|
||||
// identity; the backend owns that). Invited = awaiting their reply.
|
||||
put(
|
||||
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE,
|
||||
)
|
||||
put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType())
|
||||
put(
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
|
||||
)
|
||||
}
|
||||
|
||||
private fun EventAttendee.providerType(): Int = if (optional) {
|
||||
CalendarContract.Attendees.TYPE_OPTIONAL
|
||||
} else {
|
||||
CalendarContract.Attendees.TYPE_REQUIRED
|
||||
}
|
||||
|
||||
/** Best-effort attendee inserts (like reminders); emails are never logged. */
|
||||
private fun insertAttendees(eventId: Long, attendees: List<EventAttendee>) {
|
||||
attendees.distinctByEmail().forEach { attendee ->
|
||||
if (resolver.insert(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
attendeeValues(eventId, attendee),
|
||||
) == null
|
||||
) {
|
||||
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class AttendeeRow(
|
||||
val id: Long,
|
||||
val email: String,
|
||||
val relationship: Int,
|
||||
val type: Int,
|
||||
) {
|
||||
val key: String get() = email.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
arrayOf(
|
||||
CalendarContract.Attendees._ID,
|
||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||
),
|
||||
CalendarContract.Attendees.EVENT_ID + " = ?",
|
||||
arrayOf(eventId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
AttendeeRow(
|
||||
id = c.getLong(0),
|
||||
email = c.getString(1).orEmpty(),
|
||||
relationship = c.getInt(2),
|
||||
type = c.getInt(3),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
/**
|
||||
* Make the event's *editable* guest rows match [attendees]. Only rows we
|
||||
* own — has an email, not the organizer, not a resource — are reconciled;
|
||||
* everything else the backend put there is left untouched. Matched rows keep
|
||||
* their response status (only the required/optional type is updated if it
|
||||
* changed); dropped rows are deleted; new guests inserted. Calendula sends
|
||||
* nothing — the backend decides if anyone is notified on sync.
|
||||
*/
|
||||
private fun reconcileAttendees(eventId: Long, attendees: List<EventAttendee>) {
|
||||
val target = attendees.distinctByEmail()
|
||||
val targetByKey = target.associateBy { it.key() }
|
||||
val editable = queryAttendeeRows(eventId).filter {
|
||||
it.email.isNotBlank() &&
|
||||
it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER &&
|
||||
it.type != CalendarContract.Attendees.TYPE_RESOURCE
|
||||
}
|
||||
val editableKeys = editable.map { it.key }.toSet()
|
||||
editable.forEach { row ->
|
||||
val want = targetByKey[row.key]
|
||||
if (want == null) {
|
||||
resolver.delete(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
CalendarContract.Attendees._ID + " = ?",
|
||||
arrayOf(row.id.toString()),
|
||||
)
|
||||
} else if (row.type != want.providerType()) {
|
||||
resolver.update(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType())
|
||||
},
|
||||
CalendarContract.Attendees._ID + " = ?",
|
||||
arrayOf(row.id.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
|
||||
if (resolver.insert(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
attendeeValues(eventId, attendee),
|
||||
) == null
|
||||
) {
|
||||
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||
ContentValues().also { cv ->
|
||||
forEach { (column, value) ->
|
||||
|
||||
@@ -41,6 +41,27 @@ data class EventForm(
|
||||
*/
|
||||
val colorKey: String? = null,
|
||||
val color: Int? = null,
|
||||
/**
|
||||
* Guests the user has added/kept on the event. Calendula only writes these
|
||||
* `Attendees` rows — it has no INTERNET and never sends an invitation
|
||||
* itself; whether a guest is notified is decided downstream by the
|
||||
* calendar's backend (local: no one; CalDAV/Google: the server/account).
|
||||
* Read-only rows the form doesn't model (the organizer, resources) are
|
||||
* preserved by the data layer, not carried here.
|
||||
*/
|
||||
val attendees: List<EventAttendee> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One editable guest: the user controls the [email] (the identity we dedup and
|
||||
* write on), an optional display [name], and whether they're [optional] rather
|
||||
* than required. Response status and the organizer/resource distinction are not
|
||||
* user-editable, so they aren't modelled here.
|
||||
*/
|
||||
data class EventAttendee(
|
||||
val email: String,
|
||||
val name: String = "",
|
||||
val optional: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -55,6 +76,7 @@ enum class EventFormField {
|
||||
Availability,
|
||||
Visibility,
|
||||
Color,
|
||||
Attendees,
|
||||
}
|
||||
|
||||
enum class EventFormProblem {
|
||||
@@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
// calendar's colour.
|
||||
colorKey = eventColorKey,
|
||||
color = eventColor,
|
||||
// Only editable guests ride in the form: drop the organizer and
|
||||
// resource rows (not user-editable) and any without an email (we key
|
||||
// edits and dedup on the address). The data layer preserves the rows we
|
||||
// don't carry here, so they're never clobbered on save.
|
||||
attendees = attendees
|
||||
.filter {
|
||||
it.relationship != AttendeeRelationship.Organizer &&
|
||||
it.type != AttendeeType.Resource
|
||||
}
|
||||
.mapNotNull { a ->
|
||||
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||
EventAttendee(
|
||||
email = email,
|
||||
name = a.name,
|
||||
optional = a.type == AttendeeType.Optional,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,9 +157,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
* while the form was open. The raw row times ride along because
|
||||
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
||||
* re-deriving with the same occurrence would mask an externally moved
|
||||
* event. Not covered (the form can't write them, and the dirty-checked
|
||||
* write can't clobber them): attendees, status, the user's own response,
|
||||
* reminder methods, and a recurring event's duration.
|
||||
* event. Guests are covered (the form writes editable attendees), so an
|
||||
* external attendee change now also trips the conflict check. Still not
|
||||
* covered: status, the user's own response, reminder methods, the
|
||||
* organizer/resource rows, and a recurring event's duration.
|
||||
*/
|
||||
data class EditSnapshot(
|
||||
val form: EventForm,
|
||||
@@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||
}
|
||||
|
||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -12,6 +13,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -36,6 +38,9 @@ fun InlineTextField(
|
||||
minLines: Int = 1,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||
imeAction: ImeAction = ImeAction.Default,
|
||||
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
||||
onImeAction: (() -> Unit)? = null,
|
||||
) {
|
||||
val resolvedStyle = textStyle.copy(
|
||||
color = if (textStyle.color.isSpecified) {
|
||||
@@ -53,7 +58,11 @@ fun InlineTextField(
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
capitalization = capitalization,
|
||||
imeAction = imeAction,
|
||||
),
|
||||
keyboardActions = onImeAction?.let { action ->
|
||||
KeyboardActions(onAny = { action() })
|
||||
} ?: KeyboardActions.Default,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package de.jeanlucmakiola.calendula.ui.edit
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Patterns
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -30,7 +36,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
@@ -38,10 +43,14 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
@@ -49,11 +58,11 @@ import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -82,13 +91,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -97,6 +110,7 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
@@ -115,6 +129,8 @@ import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
@@ -429,6 +445,22 @@ private fun EventEditContent(
|
||||
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
||||
// email row (email + display name). No READ_CONTACTS — the result Intent
|
||||
// grants temporary read access, so Calendula stays no-network/no-permission.
|
||||
val context = LocalContext.current
|
||||
val pickContact = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.data?.let { uri ->
|
||||
readContactEmail(context, uri)?.let { (email, name) ->
|
||||
viewModel.addAttendee(email, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||
// The accent ties the form to the detail screen's design language: the
|
||||
// bar under the title takes the target calendar's colour.
|
||||
@@ -587,33 +619,24 @@ private fun EventEditContent(
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
// Reminders stack one per row (remove on the right edge); the
|
||||
// add chip closes the card at the bottom. The card icon centres
|
||||
// on the first row, whatever that is.
|
||||
EditCard(
|
||||
icon = Icons.Default.Notifications,
|
||||
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
||||
header = {
|
||||
when (val first = form.reminders.firstOrNull()) {
|
||||
null -> AddReminderChip(onClick = { showReminderPicker = true })
|
||||
else -> ReminderRow(
|
||||
label = reminderLabel(first),
|
||||
onRemove = { viewModel.removeReminder(first) },
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
form.reminders.drop(1).forEach { minutes ->
|
||||
ReminderRow(
|
||||
label = reminderLabel(minutes),
|
||||
onRemove = { viewModel.removeReminder(minutes) },
|
||||
)
|
||||
}
|
||||
if (form.reminders.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
AddReminderChip(onClick = { showReminderPicker = true })
|
||||
}
|
||||
// A grouped list like guests: each reminder is its own tonal card
|
||||
// with connected corners, the last row being "Add reminder".
|
||||
val reminders = form.reminders
|
||||
val rows = reminders.size + 1
|
||||
reminders.forEachIndexed { index, minutes ->
|
||||
GroupedItemCard(
|
||||
position = positionOf(index, rows),
|
||||
icon = Icons.Default.Notifications,
|
||||
title = reminderLabel(minutes),
|
||||
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
|
||||
onRemove = { viewModel.removeReminder(minutes) },
|
||||
)
|
||||
}
|
||||
AddActionCard(
|
||||
label = stringResource(R.string.event_edit_add_reminder),
|
||||
position = positionOf(reminders.size, rows),
|
||||
onClick = { showReminderPicker = true },
|
||||
)
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
||||
@@ -776,6 +799,52 @@ private fun EventEditContent(
|
||||
}
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) {
|
||||
Spacer(Modifier.height(gap))
|
||||
// Guests are an inline grouped list: each is a tonal card with a
|
||||
// role toggle + remove, and the trailing card is an inline email
|
||||
// field — type an address, press Done, it commits. Below sits an
|
||||
// honest note — Calendula never sends invitations, so whether a
|
||||
// guest is notified is the calendar backend's call.
|
||||
val guests = form.attendees
|
||||
val rows = guests.size + 1
|
||||
guests.forEachIndexed { index, attendee ->
|
||||
GuestCard(
|
||||
attendee = attendee,
|
||||
position = positionOf(index, rows),
|
||||
onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) },
|
||||
onRemove = { viewModel.removeAttendee(attendee) },
|
||||
)
|
||||
}
|
||||
AddGuestInlineCard(
|
||||
position = positionOf(guests.size, rows),
|
||||
onAdd = { viewModel.addAttendee(it) },
|
||||
onPickContact = {
|
||||
runCatching {
|
||||
pickContact.launch(
|
||||
Intent(
|
||||
Intent.ACTION_PICK,
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isLocalCalendar) {
|
||||
R.string.event_edit_attendees_note_local
|
||||
} else {
|
||||
R.string.event_edit_attendees_note_synced
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
TextButton(
|
||||
@@ -840,6 +909,7 @@ private fun EventEditContent(
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (showRecurrencePicker) {
|
||||
RecurrencePickerDialog(
|
||||
current = form.rrule,
|
||||
@@ -1262,45 +1332,253 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
||||
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
||||
}
|
||||
|
||||
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
|
||||
@Composable
|
||||
private fun ReminderRow(label: String, onRemove: () -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */
|
||||
private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape =
|
||||
when (position) {
|
||||
Position.Alone -> RoundedCornerShape(full)
|
||||
Position.Top -> RoundedCornerShape(
|
||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||
)
|
||||
IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) {
|
||||
Position.Middle -> RoundedCornerShape(small)
|
||||
Position.Bottom -> RoundedCornerShape(
|
||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||
)
|
||||
}
|
||||
|
||||
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
||||
private fun groupedGap(position: Position): Modifier = when (position) {
|
||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
Position.Bottom, Position.Alone -> Modifier
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in a grouped form list (a guest, a reminder): a leading [icon],
|
||||
* a [title] with optional [supporting] line, and a remove button — all on a
|
||||
* tonal card whose corners come from its [position] in the run.
|
||||
*/
|
||||
@Composable
|
||||
private fun GroupedItemCard(
|
||||
position: Position,
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
removeContentDescription: String,
|
||||
onRemove: () -> Unit,
|
||||
supporting: String? = null,
|
||||
titleMaxLines: Int = Int.MAX_VALUE,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = groupedShape(position),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(groupedGap(position)),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.event_edit_remove_reminder),
|
||||
modifier = Modifier.size(18.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (supporting != null) {
|
||||
Text(
|
||||
text = supporting,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (trailing != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
trailing()
|
||||
}
|
||||
IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = removeContentDescription,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One guest as a grouped-list card: avatar glyph, name/email, a tappable
|
||||
* Required/Optional role chip, and remove. The email shows as the supporting
|
||||
* line when a name is present, otherwise as the title.
|
||||
*/
|
||||
@Composable
|
||||
private fun GuestCard(
|
||||
attendee: EventAttendee,
|
||||
position: Position,
|
||||
onToggleOptional: (Boolean) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val hasName = attendee.name.isNotBlank()
|
||||
GroupedItemCard(
|
||||
position = position,
|
||||
icon = Icons.Default.Person,
|
||||
title = if (hasName) attendee.name else attendee.email,
|
||||
supporting = attendee.email.takeIf { hasName },
|
||||
titleMaxLines = 1,
|
||||
removeContentDescription = stringResource(R.string.event_edit_remove_guest),
|
||||
onRemove = onRemove,
|
||||
trailing = {
|
||||
FilterChip(
|
||||
selected = attendee.optional,
|
||||
onClick = { onToggleOptional(!attendee.optional) },
|
||||
label = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (attendee.optional) {
|
||||
R.string.event_edit_attendee_optional
|
||||
} else {
|
||||
R.string.event_edit_attendee_required
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** The trailing "Add …" action card of a grouped form list (opens a picker). */
|
||||
@Composable
|
||||
private fun AddActionCard(label: String, position: Position, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = groupedShape(position),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(groupedGap(position)),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trailing card of the guests list: an inline email field plus a "from
|
||||
* contacts" button. Type an address and press Done (or tap +) to commit a guest;
|
||||
* or pick from contacts to fill email + name at once. The field clears after,
|
||||
* ready for the next. Matches the form's inline-field input style — no dialog.
|
||||
*/
|
||||
@Composable
|
||||
private fun AddReminderChip(onClick: () -> Unit) {
|
||||
AssistChip(
|
||||
onClick = onClick,
|
||||
label = { Text(stringResource(R.string.event_edit_add)) },
|
||||
leadingIcon = {
|
||||
private fun AddGuestInlineCard(
|
||||
position: Position,
|
||||
onAdd: (String) -> Unit,
|
||||
onPickContact: () -> Unit,
|
||||
) {
|
||||
var text by rememberSaveable { mutableStateOf("") }
|
||||
val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() }
|
||||
fun commit() {
|
||||
if (valid) {
|
||||
onAdd(text.trim())
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = groupedShape(position),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(groupedGap(position)),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
},
|
||||
border = null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
InlineTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = { commit() },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
if (valid) {
|
||||
IconButton(onClick = { commit() }, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.event_edit_add_guest),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onPickContact, modifier = Modifier.size(40.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Contacts,
|
||||
contentDescription = stringResource(R.string.event_edit_add_guest_from_contacts),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the email + display name from a contact-pick result URI. No
|
||||
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
|
||||
*/
|
||||
private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>? =
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
arrayOf(
|
||||
ContactsContract.CommonDataKinds.Email.ADDRESS,
|
||||
ContactsContract.Contacts.DISPLAY_NAME,
|
||||
),
|
||||
null, null, null,
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val email = cursor.getString(0).orEmpty().trim()
|
||||
val name = cursor.getString(1).orEmpty().trim()
|
||||
email.takeIf { it.isNotEmpty() }?.let { it to name }
|
||||
}
|
||||
|
||||
|
||||
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
@@ -1309,6 +1587,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||
EventFormField.Color -> R.string.event_edit_color
|
||||
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||
}
|
||||
|
||||
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
@@ -1319,6 +1598,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
EventFormField.Visibility -> Icons.Default.Lock
|
||||
EventFormField.Color -> Icons.Default.Palette
|
||||
EventFormField.Attendees -> Icons.Default.People
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,6 +77,15 @@
|
||||
<string name="event_edit_add">Hinzufügen</string>
|
||||
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
|
||||
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
|
||||
<string name="event_edit_attendees">Gäste</string>
|
||||
<string name="event_edit_add_guest">Gast hinzufügen</string>
|
||||
<string name="event_edit_add_guest_hint">Gast per E-Mail hinzufügen…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Aus Kontakten hinzufügen</string>
|
||||
<string name="event_edit_remove_guest">Gast entfernen</string>
|
||||
<string name="event_edit_attendee_required">Erforderlich</string>
|
||||
<string name="event_edit_attendee_optional">Optional</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula versendet keine Einladungen. Dein Kalenderkonto sendet Gästen bei der Synchronisierung unter Umständen eine E-Mail.</string>
|
||||
<string name="event_edit_attendees_note_local">Auf diesem Gerät gespeichert. Niemand wird benachrichtigt.</string>
|
||||
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
|
||||
<string name="reminder_unit_minutes">Minuten</string>
|
||||
<string name="reminder_unit_hours">Stunden</string>
|
||||
|
||||
@@ -78,6 +78,15 @@
|
||||
<string name="event_edit_add">Add</string>
|
||||
<string name="event_edit_add_reminder">Add reminder</string>
|
||||
<string name="event_edit_remove_reminder">Remove reminder</string>
|
||||
<string name="event_edit_attendees">Guests</string>
|
||||
<string name="event_edit_add_guest">Add guest</string>
|
||||
<string name="event_edit_add_guest_hint">Add a guest by email…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Add from contacts</string>
|
||||
<string name="event_edit_remove_guest">Remove guest</string>
|
||||
<string name="event_edit_attendee_required">Required</string>
|
||||
<string name="event_edit_attendee_optional">Optional</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula doesn\'t send invitations. Your calendar account may email guests when it syncs.</string>
|
||||
<string name="event_edit_attendees_note_local">Stored on this device. No one is notified.</string>
|
||||
<string name="event_edit_reminder_custom">Custom</string>
|
||||
<string name="reminder_unit_minutes">minutes</string>
|
||||
<string name="reminder_unit_hours">hours</string>
|
||||
|
||||
@@ -211,12 +211,55 @@ class EventFormTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `changes the form cannot write do not fake a conflict`() {
|
||||
fun `a reminder-method change the form cannot write does not fake a conflict`() {
|
||||
// The form carries reminder minutes only, not the method — so a method
|
||||
// flip the form can't express must not read as an external change.
|
||||
val loaded = detail(reminders = listOf(Reminder(10, ReminderMethod.Alert)))
|
||||
.toEditSnapshot(0L, 3_600_000L, berlin)
|
||||
val fresh = detail(reminders = listOf(Reminder(10, ReminderMethod.Email)))
|
||||
.toEditSnapshot(0L, 3_600_000L, berlin)
|
||||
assertThat(fresh).isEqualTo(loaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an external guest change now trips the conflict check`() {
|
||||
// Attendees are writable now, so they ride in the snapshot: adding a
|
||||
// guest externally is a real conflict, not invisible.
|
||||
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
|
||||
val fresh = detail(
|
||||
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)),
|
||||
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction)),
|
||||
).toEditSnapshot(0L, 3_600_000L, berlin)
|
||||
assertThat(fresh).isEqualTo(loaded)
|
||||
assertThat(fresh).isNotEqualTo(loaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm carries editable guests with name email and optional role`() {
|
||||
val prefilled = detail(
|
||||
attendees = listOf(
|
||||
Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted, type = AttendeeType.Required),
|
||||
Attendee("", "bob@example.org", AttendeeStatus.NeedsAction, type = AttendeeType.Optional),
|
||||
),
|
||||
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||
assertThat(prefilled.attendees).containsExactly(
|
||||
EventAttendee(email = "ada@example.org", name = "Ada", optional = false),
|
||||
EventAttendee(email = "bob@example.org", name = "", optional = true),
|
||||
).inOrder()
|
||||
assertThat(prefilled.populatedFields()).contains(EventFormField.Attendees)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toEditForm drops the organizer, resources and guests without an email`() {
|
||||
val prefilled = detail(
|
||||
attendees = listOf(
|
||||
Attendee("Org", "org@example.org", AttendeeStatus.Accepted, AttendeeRelationship.Organizer),
|
||||
Attendee("Room A", "room@example.org", AttendeeStatus.Accepted, type = AttendeeType.Resource),
|
||||
Attendee("No address", null, AttendeeStatus.NeedsAction),
|
||||
Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction),
|
||||
),
|
||||
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||
assertThat(prefilled.attendees).containsExactly(
|
||||
EventAttendee(email = "ada@example.org", name = "Ada", optional = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user