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/CHANGELOG.md b/CHANGELOG.md
index fc7f709..8882c81 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,45 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [2.8.0] — 2026-06-23
+
+### Added
+- Find events fast. A search button in the top bar of every calendar view opens
+ a search box — type a couple of letters and matching events (by title,
+ location or description) appear, soonest first with past events below. Tap a
+ result to open it. Search covers your whole calendar, not just what's on
+ screen, and skips calendars you've hidden. A recurring event shows its next
+ occurrence rather than the date the series first started.
+- A current-time line in the day and week views. A thin coloured line marks the
+ present moment across today's column, so you can see at a glance where you are
+ in the day. It updates every minute and only appears when today is in view.
+- Add and remove event guests. The create/edit form now has a Guests section:
+ add people by email or pick them from your contacts, mark each as required or
+ optional, and remove them. Calendula never sends invitations itself (it has no
+ internet access) — it only records the guests; if the event lives on a synced
+ account, that account may email them when it syncs, and on a local calendar no
+ one is notified. The form tells you which applies. Picking a guest from
+ contacts needs no contacts permission.
+- Pick a location from your contacts. A contacts button beside the location
+ field drops a contact's address straight into an event — handy for a meeting
+ at someone's home or office. Like the guest picker, it needs no contacts
+ permission.
+- A "New event" Quick Settings tile. Add it to your quick settings to jump
+ straight into the new-event form from anywhere. Settings → New event has a
+ one-tap button to add the tile (Android 13+); on older versions you can add it
+ from the quick-settings editor.
+- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
+ reminder; snooze hides it and brings it back after a delay you pick in
+ Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
+ system won't re-post a reminder on its own, so Calendula schedules an exact
+ alarm to bring a snoozed one back on time.
+
+### Changed
+- Event details now show each guest's email beneath their name, instead of only
+ when no name is available.
+- Crash and problem reports now open on the project's public Codeberg tracker,
+ where anyone can register and file an issue. Nothing is sent automatically —
+ you still review the report and submit it yourself in the browser.
## [2.7.5] — 2026-06-21
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 28b8251..0a076a4 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
- versionCode = 20705
- versionName = "2.7.5"
+ versionCode = 20800
+ versionName = "2.8.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c37243e..d91eb70 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,6 +13,17 @@
-->
+
+
+
+
+
+
+
+
+
+
@@ -91,6 +116,13 @@
+
+
+
fun eventDetail(eventId: Long): EventDetail?
+ /**
+ * Master/one-off events whose title, description or location contains
+ * [query] (case-insensitive), across all calendars, newest first. Reads the
+ * Events table directly so the search is unbounded in time; exception rows
+ * are excluded (see [SearchProjection]). [query] is assumed non-blank.
+ */
+ fun searchEvents(query: String): List
+
/**
* The event-colour palette the calendar's account publishes
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
@@ -297,6 +306,89 @@ class AndroidCalendarDataSource @Inject constructor(
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
}
+ override fun searchEvents(query: String): List {
+ ensureObserversRegistered()
+ val trimmed = query.trim()
+ if (trimmed.isEmpty()) return emptyList()
+ // Escape the SQL LIKE wildcards so a literal % or _ in the query matches
+ // itself instead of acting as a wildcard.
+ val escaped = trimmed
+ .replace("\\", "\\\\")
+ .replace("%", "\\%")
+ .replace("_", "\\_")
+ val like = "%$escaped%"
+ val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
+ "${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
+ "${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
+ val selection = "($match) AND " +
+ "${CalendarContract.Events.DELETED} = 0 AND " +
+ "${CalendarContract.Events.ORIGINAL_ID} IS NULL"
+ return resolver.query(
+ CalendarContract.Events.CONTENT_URI,
+ SearchProjection.COLUMNS,
+ selection,
+ arrayOf(like, like, like),
+ CalendarContract.Events.DTSTART + " DESC",
+ )?.use { c ->
+ val reader = CursorColumnReader(c)
+ val out = ArrayList(c.count)
+ while (c.moveToNext()) {
+ val base = reader.toSearchResult() ?: continue
+ // A recurring master's DTSTART is the series start; show its
+ // nearest occurrence instead so the date is the one the user
+ // actually cares about (and sorting reflects it).
+ val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
+ !reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
+ out += if (recurring) {
+ nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
+ base.copy(
+ start = begin.toKotlinInstantFromEpochMillis(),
+ end = end.toKotlinInstantFromEpochMillis(),
+ )
+ } ?: base
+ } else {
+ base
+ }
+ }
+ out
+ } ?: emptyList()
+ }
+
+ /**
+ * The occurrence of [eventId] nearest to now: the soonest upcoming one
+ * within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
+ * within the same window behind. Null when neither exists (e.g. a series
+ * that only starts further out than the window) — the caller then keeps the
+ * series-start DTSTART. Returns (begin, end) epoch millis.
+ */
+ private fun nearestOccurrenceMillis(eventId: Long): Pair? {
+ val now = System.currentTimeMillis()
+ return occurrenceInWindow(eventId, now, now + OCCURRENCE_WINDOW_MILLIS, soonestFirst = true)
+ ?: occurrenceInWindow(eventId, now - OCCURRENCE_WINDOW_MILLIS, now, soonestFirst = false)
+ }
+
+ private fun occurrenceInWindow(
+ eventId: Long,
+ beginMillis: Long,
+ endMillis: Long,
+ soonestFirst: Boolean,
+ ): Pair? {
+ val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
+ ContentUris.appendId(this, beginMillis)
+ ContentUris.appendId(this, endMillis)
+ }.build()
+ val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC"
+ return resolver.query(
+ uri,
+ arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END),
+ "${CalendarContract.Instances.EVENT_ID} = ?",
+ arrayOf(eventId.toString()),
+ order,
+ )?.use { c ->
+ if (!c.moveToFirst()) null else c.getLong(0) to c.getLong(1)
+ }
+ }
+
override fun eventDetail(eventId: Long): EventDetail? {
val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId)
@@ -536,6 +628,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 +661,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 +689,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 +823,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) ->
@@ -814,5 +1046,12 @@ class AndroidCalendarDataSource @Inject constructor(
* together (by account) in the filter sheet and calendar manager.
*/
const val LOCAL_ACCOUNT_NAME = "Calendula"
+
+ /**
+ * How far ahead/behind a search looks for a recurring event's nearest
+ * occurrence (~2 years). Wide enough for everyday series; a series that
+ * next fires beyond it falls back to its series-start date.
+ */
+ const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
}
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
index 4dcaac1..27c20ce 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt
@@ -16,6 +16,13 @@ interface CalendarRepository {
fun instances(range: ClosedRange): Flow>
suspend fun eventDetail(eventId: Long): EventDetail
+ /**
+ * Events whose title, description or location contains [query], with hidden
+ * calendars removed and newest first. Empty when [query] is blank. Searches
+ * the whole history/future (see [CalendarDataSource.searchEvents]).
+ */
+ suspend fun searchEvents(query: String): List
+
/**
* The event-colour palette a calendar's account publishes; empty when it
* exposes none (see [CalendarDataSource.eventColorPalette]).
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
index 749c429..c7949ec 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt
@@ -80,6 +80,13 @@ class CalendarRepositoryImpl @Inject constructor(
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
}
+ override suspend fun searchEvents(query: String): List = withContext(io) {
+ if (query.isBlank()) return@withContext emptyList()
+ val hidden = prefs.hiddenCalendarIds.first()
+ dataSource.searchEvents(query)
+ .let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } }
+ }
+
override suspend fun eventColorPalette(calendarId: Long): List =
withContext(io) { dataSource.eventColorPalette(calendarId) }
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
index 0c190f7..5a362bf 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt
@@ -139,6 +139,44 @@ internal object EventExportProjection {
const val IDX_CALENDAR_ID = 13
}
+/**
+ * Master/one-off Events rows matched by a full-text search. Like
+ * [EventExportProjection] it reads the Events table directly (so the search is
+ * unbounded in time), carrying DURATION for recurring rows that have no DTEND.
+ * Colour folds the calendar fallback like [InstanceProjection].
+ */
+internal object SearchProjection {
+ val COLUMNS: Array = arrayOf(
+ CalendarContract.Events._ID,
+ CalendarContract.Events.CALENDAR_ID,
+ CalendarContract.Events.TITLE,
+ CalendarContract.Events.DTSTART,
+ CalendarContract.Events.DTEND,
+ CalendarContract.Events.DURATION,
+ CalendarContract.Events.ALL_DAY,
+ CalendarContract.Events.EVENT_COLOR,
+ CalendarContract.Events.CALENDAR_COLOR,
+ CalendarContract.Events.EVENT_LOCATION,
+ // Recurrence markers: a non-empty RRULE or RDATE means the result should
+ // display its nearest occurrence, not the series-start DTSTART.
+ CalendarContract.Events.RRULE,
+ CalendarContract.Events.RDATE,
+ )
+
+ const val IDX_ID = 0
+ const val IDX_CALENDAR_ID = 1
+ const val IDX_TITLE = 2
+ const val IDX_DTSTART = 3
+ const val IDX_DTEND = 4
+ const val IDX_DURATION = 5
+ const val IDX_ALL_DAY = 6
+ const val IDX_EVENT_COLOR = 7
+ const val IDX_CALENDAR_COLOR = 8
+ const val IDX_LOCATION = 9
+ const val IDX_RRULE = 10
+ const val IDX_RDATE = 11
+}
+
internal object AttendeeProjection {
val COLUMNS: Array = arrayOf(
CalendarContract.Attendees.ATTENDEE_NAME,
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt
new file mode 100644
index 0000000..4c09781
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt
@@ -0,0 +1,40 @@
+package de.jeanlucmakiola.calendula.data.calendar
+
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
+
+/**
+ * Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
+ * Instances query this reads the series master, so there is no instance id (the
+ * event id stands in as the list key) and recurring rows carry DURATION instead
+ * of DTEND — reconstruct the end the same way the `.ics` export does.
+ */
+internal fun ColumnReader.toSearchResult(): EventInstance? {
+ val dtStart = getLong(SearchProjection.IDX_DTSTART)
+ if (dtStart < 0L) return null
+ val end = when {
+ !isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
+ else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
+ }.coerceAtLeast(dtStart)
+
+ val rawTitle = getString(SearchProjection.IDX_TITLE)
+ val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
+ val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
+ getInt(SearchProjection.IDX_CALENDAR_COLOR)
+ } else {
+ getInt(SearchProjection.IDX_EVENT_COLOR)
+ }
+ val eventId = getLong(SearchProjection.IDX_ID)
+
+ return EventInstance(
+ instanceId = eventId,
+ eventId = eventId,
+ calendarId = getLong(SearchProjection.IDX_CALENDAR_ID),
+ title = title,
+ start = dtStart.toKotlinInstantFromEpochMillis(),
+ end = end.toKotlinInstantFromEpochMillis(),
+ isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
+ color = color,
+ location = getString(SearchProjection.IDX_LOCATION),
+ )
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
index da8393b..831eb26 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
@@ -177,6 +177,20 @@ class SettingsPrefs @Inject constructor(
store.edit { it[ALLDAY_REMINDER_TIME_KEY] = minutesOfDay.coerceIn(0, MINUTES_PER_DAY - 1) }
}
+ /**
+ * How long the notification "Snooze" action defers a reminder, in minutes.
+ * Tapping Snooze cancels the notification and re-posts it after this delay
+ * via an exact alarm (the calendar provider won't re-fire). Default 10 min;
+ * clamped to at least 1.
+ */
+ val snoozeMinutes: Flow = store.data.map { prefs ->
+ (prefs[SNOOZE_MINUTES_KEY] ?: DEFAULT_SNOOZE_MINUTES).coerceAtLeast(1)
+ }
+
+ suspend fun setSnoozeMinutes(minutes: Int) {
+ store.edit { it[SNOOZE_MINUTES_KEY] = minutes.coerceAtLeast(1) }
+ }
+
/**
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
* keyed by calendar id. A calendar **present** in the map overrides the global
@@ -243,6 +257,9 @@ class SettingsPrefs @Inject constructor(
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
private const val MINUTES_PER_DAY = 1_440
+ internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
+ /** Default snooze delay for the notification "Snooze" action. */
+ const val DEFAULT_SNOOZE_MINUTES = 10
internal val CALENDAR_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_reminder_override")
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt
new file mode 100644
index 0000000..b0a57fc
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderActionReceiver.kt
@@ -0,0 +1,124 @@
+package de.jeanlucmakiola.calendula.data.reminders
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Handles the "Snooze" and "Dismiss" actions on a reminder notification, plus
+ * the internal re-show when a snooze elapses. All three are app-internal
+ * intents (notification action buttons and our own [ReminderSnoozeScheduler]
+ * alarm), so the receiver is not exported.
+ *
+ * - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
+ * already fired, so nothing re-posts it.
+ * - **Snooze** cancels the notification and schedules an exact alarm to re-show
+ * it after the user's snooze delay.
+ * - **Show** (the alarm) re-posts the same notification, so the user can snooze
+ * or dismiss it again.
+ */
+@AndroidEntryPoint
+class ReminderActionReceiver : BroadcastReceiver() {
+
+ @Inject lateinit var notifier: ReminderNotifier
+ @Inject lateinit var scheduler: ReminderSnoozeScheduler
+ @Inject lateinit var settingsPrefs: SettingsPrefs
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val alert = alertFrom(intent) ?: return
+ when (intent.action) {
+ ACTION_DISMISS -> notifier.cancel(alert)
+
+ ACTION_SNOOZE -> {
+ // Cancel now so the notification doesn't linger until the alarm;
+ // the snooze delay read is the only async work.
+ notifier.cancel(alert)
+ val pendingResult = goAsync()
+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
+ try {
+ val minutes = settingsPrefs.snoozeMinutes.first()
+ val triggerAt = System.currentTimeMillis() + minutes * 60_000L
+ scheduler.schedule(alert, triggerAt)
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+
+ ACTION_SHOW -> {
+ val pendingResult = goAsync()
+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
+ try {
+ if (settingsPrefs.remindersEnabled.first() && notifier.canPost()) {
+ notifier.post(alert)
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val ACTION_SNOOZE = "de.jeanlucmakiola.calendula.reminders.SNOOZE"
+ const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
+ const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
+
+ private const val EXTRA_ALERT_ID = "alert_id"
+ private const val EXTRA_EVENT_ID = "event_id"
+ private const val EXTRA_BEGIN = "begin"
+ private const val EXTRA_END = "end"
+ private const val EXTRA_TITLE = "title"
+ private const val EXTRA_LOCATION = "location"
+ private const val EXTRA_ALL_DAY = "all_day"
+
+ /** An explicit intent to this receiver carrying [alert] as extras. */
+ fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
+ Intent(context, ReminderActionReceiver::class.java).apply {
+ this.action = action
+ putExtra(EXTRA_ALERT_ID, alert.alertId)
+ putExtra(EXTRA_EVENT_ID, alert.eventId)
+ putExtra(EXTRA_BEGIN, alert.beginMillis)
+ putExtra(EXTRA_END, alert.endMillis)
+ putExtra(EXTRA_TITLE, alert.title)
+ putExtra(EXTRA_LOCATION, alert.location)
+ putExtra(EXTRA_ALL_DAY, alert.isAllDay)
+ }
+
+ /**
+ * A stable request code per (alert, action) so the three PendingIntents
+ * of one notification stay distinct and don't clobber each other.
+ */
+ fun requestCode(alert: ReminderAlert, action: String): Int {
+ val actionOffset = when (action) {
+ ACTION_SNOOZE -> 1
+ ACTION_DISMISS -> 2
+ ACTION_SHOW -> 3
+ else -> 0
+ }
+ return alert.alertId.toInt() * 8 + actionOffset
+ }
+
+ private fun alertFrom(intent: Intent): ReminderAlert? {
+ if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
+ return ReminderAlert(
+ alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
+ eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
+ beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
+ endMillis = intent.getLongExtra(EXTRA_END, 0L),
+ title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
+ location = intent.getStringExtra(EXTRA_LOCATION),
+ isAllDay = intent.getBooleanExtra(EXTRA_ALL_DAY, false),
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt
index 5c60011..358d9ca 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt
@@ -61,6 +61,16 @@ class ReminderNotifier @Inject constructor(
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(detailIntent(alert))
+ .addAction(
+ R.drawable.ic_notification_snooze,
+ context.getString(R.string.reminder_action_snooze),
+ actionIntent(alert, ReminderActionReceiver.ACTION_SNOOZE),
+ )
+ .addAction(
+ R.drawable.ic_notification_dismiss,
+ context.getString(R.string.reminder_action_dismiss),
+ actionIntent(alert, ReminderActionReceiver.ACTION_DISMISS),
+ )
.build()
try {
NotificationManagerCompat.from(context)
@@ -71,6 +81,19 @@ class ReminderNotifier @Inject constructor(
}
}
+ /** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
+ fun cancel(alert: ReminderAlert) {
+ NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
+ }
+
+ private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ ReminderActionReceiver.requestCode(alert, action),
+ ReminderActionReceiver.intent(context, action, alert),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context,
/* requestCode = */ alert.alertId.toInt(),
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt
new file mode 100644
index 0000000..4bb78f5
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderSnoozeScheduler.kt
@@ -0,0 +1,55 @@
+package de.jeanlucmakiola.calendula.data.reminders
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.os.Build
+import androidx.core.content.getSystemService
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Schedules a one-off exact alarm that re-shows a snoozed reminder.
+ *
+ * The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
+ * broadcast (the Etar model), but a snoozed reminder has no provider backing —
+ * its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
+ * A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
+ * back to an inexact allow-while-idle alarm only if the OS withholds the
+ * exact-alarm capability (API 31–32 where the user revoked it).
+ */
+@Singleton
+class ReminderSnoozeScheduler @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+
+ fun schedule(alert: ReminderAlert, triggerAtMillis: Long) {
+ val alarmManager = context.getSystemService() ?: return
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ ReminderActionReceiver.requestCode(alert, ReminderActionReceiver.ACTION_SHOW),
+ ReminderActionReceiver.intent(context, ReminderActionReceiver.ACTION_SHOW, alert),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ if (canScheduleExact(alarmManager)) {
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
+ )
+ } else {
+ // Exact alarms revoked (API 31–32): an inexact wake is the honest
+ // best we can do without nagging for SCHEDULE_EXACT_ALARM.
+ alarmManager.setAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
+ )
+ }
+ }
+
+ /**
+ * True on API < 31 (no restriction), and on 31+ when the exact-alarm
+ * capability is held — auto-granted via `USE_EXACT_ALARM` on API 33+
+ * (Calendula is a calendar app), user-revocable on 31–32.
+ */
+ private fun canScheduleExact(alarmManager: AlarmManager): Boolean =
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarmManager.canScheduleExactAlarms()
+}
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/qs/NewEventTileService.kt b/app/src/main/java/de/jeanlucmakiola/calendula/qs/NewEventTileService.kt
new file mode 100644
index 0000000..757aef6
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/qs/NewEventTileService.kt
@@ -0,0 +1,43 @@
+package de.jeanlucmakiola.calendula.qs
+
+import android.app.PendingIntent
+import android.os.Build
+import android.service.quicksettings.TileService
+import de.jeanlucmakiola.calendula.MainActivity
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Clock
+
+/**
+ * Quick Settings tile: tapping it opens the create-event form on today — the
+ * same action as the launcher "New event" shortcut and the agenda widget's "+".
+ * A stateless action tile, so there is no on/off state to keep in sync.
+ */
+class NewEventTileService : TileService() {
+
+ // The pre-34 branch intentionally uses the deprecated Intent overload: it is
+ // the only form available below UpsideDownCake, and is reached only there.
+ @Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
+ override fun onClick() {
+ super.onClick()
+ val today = Clock.System.now()
+ .toLocalDateTime(TimeZone.currentSystemDefault()).date
+ val intent = MainActivity.openCreateIntent(this, today)
+ // Launch only once the device is unlocked: creating an event behind the
+ // keyguard makes no sense, and the shade can't start an activity over a
+ // locked screen anyway.
+ unlockAndRun {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ val pending = PendingIntent.getActivity(
+ this,
+ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ startActivityAndCollapse(pending)
+ } else {
+ startActivityAndCollapse(intent)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
index 941f80f..91063f8 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt
@@ -26,6 +26,7 @@ import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
+import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.datetime.LocalDate
@@ -97,6 +98,12 @@ fun CalendarHost(
var showSettings by rememberSaveable { mutableStateOf(false) }
val onOpenSettings = { showSettings = true }
+ // Full-text search — its own overlay, opened from each calendar screen's
+ // top bar. Sits below the detail/edit overlays so tapping a result reveals
+ // the detail on top and backing out returns to the results.
+ var showSearch by rememberSaveable { mutableStateOf(false) }
+ val onOpenSearch = { showSearch = true }
+
// Calendar manager (reached from Settings) — its own overlay so it slides
// over Settings and survives view switches.
var showCalendars by rememberSaveable { mutableStateOf(false) }
@@ -137,16 +144,37 @@ fun CalendarHost(
}
}
+ // Close every overlay that can sit over the calendar, so an externally
+ // requested destination (a widget/shortcut/QS-tile launch) is revealed on
+ // top instead of underneath whatever the user had open.
+ fun dismissCoveringOverlays() {
+ showSettings = false
+ showCalendars = false
+ detailKey = null
+ editKey = null
+ importUri = null
+ importForm = null
+ }
+
// A home-screen widget launch asks to open a date (→ day view) or start a
// create. Handled once and cleared, mirroring [requestedDetailKey].
LaunchedEffect(widgetNavRequest) {
when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> {
+ // Reveal the day view: drop any overlay that would cover it, so
+ // an external date-open doesn't land under an open Settings/form.
+ dismissCoveringOverlays()
+ createDateIso = null
pendingDayIso = req.dateIso
view = CalendarView.Day
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> {
+ // External "new event" entries (QS tile / launcher shortcut /
+ // widget) must land on top of whatever is open — the form overlay
+ // sits below Settings/calendars in the Box, so without this it
+ // would open hidden underneath them.
+ dismissCoveringOverlays()
val iso = req.dateIso ?: Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
heldCreateIso = iso
@@ -168,6 +196,7 @@ fun CalendarHost(
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
+ onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Day -> DayScreen(
@@ -175,6 +204,7 @@ fun CalendarHost(
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
+ onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
)
@@ -183,6 +213,7 @@ fun CalendarHost(
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onOpenSettings = onOpenSettings,
+ onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
CalendarView.Agenda -> AgendaScreen(
@@ -190,10 +221,24 @@ fun CalendarHost(
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
+ onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
)
}
+ // Search overlay — below detail/edit in the Box so a tapped result's
+ // detail screen draws on top, and closing it returns to the results.
+ AnimatedVisibility(
+ visible = showSearch,
+ enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
+ exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
+ ) {
+ SearchScreen(
+ onBack = { showSearch = false },
+ onEventClick = onEventClick,
+ )
+ }
+
// Prefer the live key; fall back to the held one only while sliding out.
val activeKey = detailKey ?: heldKey
AnimatedVisibility(
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt
index 83d56a9..ef4acbb 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt
@@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -75,6 +76,7 @@ fun AgendaScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
+ onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
@@ -119,6 +121,7 @@ fun AgendaScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
+ onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -274,6 +277,7 @@ private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
+ onOpenSearch: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -292,6 +296,12 @@ private fun AgendaTopBar(
}
},
actions = {
+ IconButton(onClick = onOpenSearch) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(R.string.search_action),
+ )
+ }
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
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/common/NowLine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt
new file mode 100644
index 0000000..faa243f
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/NowLine.kt
@@ -0,0 +1,90 @@
+package de.jeanlucmakiola.calendula.ui.common
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.delay
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+private val DotSize = 8.dp
+private val LineThickness = 2.dp
+
+/**
+ * Current wall-clock instant that updates once a minute, re-aligning to each
+ * minute boundary (rather than ticking a fixed 60 s) so it never drifts toward
+ * the middle of a minute. Drives the "now" line in the day/week grids.
+ */
+@Composable
+private fun rememberCurrentMinute(): State {
+ val instant = remember { mutableStateOf(Clock.System.now()) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ val now = Clock.System.now()
+ instant.value = now
+ delay(60_000L - now.toEpochMilliseconds() % 60_000L)
+ }
+ }
+ return instant
+}
+
+/**
+ * A thin "current time" indicator — a leading dot plus a line — drawn across a
+ * day column. Positioned on the same [hourHeight] scale the event blocks use so
+ * it lines up with the grid, and refreshed each minute. Renders nothing unless
+ * the wall clock is on [date]; callers mount it only for the column showing
+ * today, so the per-minute tick runs on a single column.
+ */
+@Composable
+fun NowLine(
+ date: LocalDate,
+ hourHeight: Dp,
+ modifier: Modifier = Modifier,
+) {
+ val now by rememberCurrentMinute()
+ val local = now.toLocalDateTime(TimeZone.currentSystemDefault())
+ if (local.date != date) return
+
+ val minutes = local.hour * 60 + local.minute
+ val top = hourHeight * (minutes / 60f)
+ val color = MaterialTheme.colorScheme.primary
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(DotSize)
+ .offset(y = top - DotSize / 2),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(LineThickness)
+ .background(color),
+ )
+ // The dot anchors the line to the gutter edge, mirroring the standard
+ // calendar "now" marker; drawn after the line so it sits on top.
+ Box(
+ modifier = Modifier
+ .size(DotSize)
+ .background(color, CircleShape),
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt
index 9e7ef84..775e07b 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt
@@ -26,6 +26,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
@@ -71,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
+import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.next
@@ -105,6 +107,7 @@ fun DayScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
+ onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
@@ -188,6 +191,7 @@ fun DayScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
+ onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -340,6 +344,7 @@ private fun DayTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
+ onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -358,6 +363,12 @@ private fun DayTopBar(
}
},
actions = {
+ IconButton(onClick = onOpenSearch) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(R.string.search_action),
+ )
+ }
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -496,6 +507,7 @@ private fun Timeline(
blocks = state.timed,
dark = dark,
date = state.date,
+ today = state.today,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
@@ -512,6 +524,7 @@ private fun DayColumnCard(
blocks: List,
dark: Boolean,
date: LocalDate,
+ today: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
@@ -556,6 +569,10 @@ private fun DayColumnCard(
.padding(horizontal = 1.dp),
)
}
+ // Current-time line, on top of the events, only on today's column.
+ if (date == today) {
+ NowLine(date = date, hourHeight = HOUR_HEIGHT)
+ }
}
}
}
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..88fb610 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,37 @@ 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)
+ }
+ }
+ }
+ }
+
+ // Pick a postal address from contacts: the same one-shot picker, scoped to
+ // address rows, dropping the chosen contact's formatted address into the
+ // location field. Same no-permission guarantee as the guest picker above.
+ val pickContactAddress = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ result.data?.data?.let { uri ->
+ readContactAddress(context, uri)?.let { address ->
+ viewModel.setLocation(address)
+ }
+ }
+ }
+ }
+
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.
@@ -560,11 +607,39 @@ private fun EventEditContent(
icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location),
) {
- InlineField(
- value = form.location,
- onValueChange = viewModel::setLocation,
- placeholder = stringResource(R.string.event_detail_location),
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ InlineField(
+ value = form.location,
+ onValueChange = viewModel::setLocation,
+ placeholder = stringResource(R.string.event_detail_location),
+ modifier = Modifier
+ .weight(1f)
+ .padding(vertical = 4.dp),
+ )
+ IconButton(
+ onClick = {
+ runCatching {
+ pickContactAddress.launch(
+ Intent(
+ Intent.ACTION_PICK,
+ ContactsContract.CommonDataKinds.StructuredPostal
+ .CONTENT_URI,
+ ),
+ )
+ }
+ },
+ modifier = Modifier.size(40.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Contacts,
+ contentDescription = stringResource(
+ R.string.event_edit_location_from_contacts,
+ ),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ }
}
}
@@ -587,33 +662,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 +842,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 +952,7 @@ private fun EventEditContent(
)
}
+
if (showRecurrencePicker) {
RecurrencePickerDialog(
current = form.rrule,
@@ -1262,45 +1375,274 @@ 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 }
+ }
+
+/**
+ * Read the formatted postal address from a contact-pick result URI. No
+ * READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
+ * The provider's formatted address is multi-line; collapse it to one line so
+ * it sits cleanly in the single-line location field.
+ */
+private fun readContactAddress(context: Context, uri: Uri): String? =
+ context.contentResolver.query(
+ uri,
+ arrayOf(ContactsContract.CommonDataKinds.StructuredPostal.FORMATTED_ADDRESS),
+ null, null, null,
+ )?.use { cursor ->
+ if (!cursor.moveToFirst()) return@use null
+ cursor.getString(0).orEmpty()
+ .lines()
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .joinToString(", ")
+ .takeIf { it.isNotEmpty() }
+ }
+
+
private fun fieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
@@ -1309,6 +1651,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 +1662,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/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
index 9210063..f2d5b28 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
@@ -23,6 +23,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -86,6 +87,7 @@ fun MonthScreen(
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit,
onOpenSettings: () -> Unit,
+ onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
@@ -161,6 +163,7 @@ fun MonthScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
+ onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -261,6 +264,7 @@ private fun MonthTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
+ onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -279,6 +283,12 @@ private fun MonthTopBar(
}
},
actions = {
+ IconButton(onClick = onOpenSearch) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(R.string.search_action),
+ )
+ }
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt
new file mode 100644
index 0000000..5309844
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt
@@ -0,0 +1,257 @@
+package de.jeanlucmakiola.calendula.ui.search
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.SearchOff
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import de.jeanlucmakiola.calendula.R
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.ui.common.GroupedRow
+import de.jeanlucmakiola.calendula.ui.common.InlineTextField
+import de.jeanlucmakiola.calendula.ui.common.Position
+import de.jeanlucmakiola.calendula.ui.common.currentLocale
+import de.jeanlucmakiola.calendula.ui.common.pastelize
+import de.jeanlucmakiola.calendula.ui.common.positionOf
+import java.time.Instant as JavaInstant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+/**
+ * Full-text event search (top-bar entry). Type a query → matching events
+ * (title / location / description) across the whole calendar, newest-relevant
+ * first; tap a result to open its detail. A full-screen overlay hosted by
+ * [de.jeanlucmakiola.calendula.ui.CalendarHost].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchScreen(
+ onBack: () -> Unit,
+ onEventClick: (EventInstance) -> Unit,
+ modifier: Modifier = Modifier,
+ viewModel: SearchViewModel = hiltViewModel(),
+) {
+ val query by viewModel.query.collectAsStateWithLifecycle()
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val focusRequester = remember { FocusRequester() }
+ val keyboard = LocalSoftwareKeyboardController.current
+
+ // Each fresh open starts blank and straight into typing. The ViewModel is
+ // activity-scoped so it outlives the overlay; clearing on (re)enter is what
+ // resets a previous search. Peeking a result doesn't re-run this (the screen
+ // stays composed under the detail), so backing out keeps the query.
+ LaunchedEffect(Unit) {
+ viewModel.setQuery("")
+ focusRequester.requestFocus()
+ keyboard?.show()
+ }
+ BackHandler(onBack = onBack)
+
+ Scaffold(
+ modifier = modifier,
+ containerColor = MaterialTheme.colorScheme.surface,
+ topBar = {
+ TopAppBar(
+ title = {
+ InlineTextField(
+ value = query,
+ onValueChange = viewModel::setQuery,
+ placeholder = stringResource(R.string.search_hint),
+ imeAction = ImeAction.Search,
+ onImeAction = { keyboard?.hide() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.search_back),
+ )
+ }
+ },
+ actions = {
+ if (query.isNotEmpty()) {
+ IconButton(onClick = { viewModel.setQuery("") }) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(R.string.search_clear),
+ )
+ }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ ),
+ )
+ },
+ ) { padding ->
+ // imePadding shrinks the content by the keyboard, so the centered
+ // idle/empty message re-centres in the space above it (and the results
+ // list lifts clear of the keyboard too).
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .imePadding(),
+ ) {
+ when (val s = state) {
+ SearchUiState.Idle -> SearchMessage(
+ icon = null,
+ text = stringResource(R.string.search_idle_hint),
+ )
+ is SearchUiState.Empty -> SearchMessage(
+ icon = Icons.Default.SearchOff,
+ text = stringResource(R.string.search_empty, s.query),
+ )
+ is SearchUiState.Results -> SearchResults(
+ events = s.events,
+ onEventClick = onEventClick,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SearchResults(
+ events: List,
+ onEventClick: (EventInstance) -> Unit,
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
+ ) {
+ itemsIndexed(
+ items = events,
+ key = { _, event -> event.eventId },
+ ) { index, event ->
+ SearchResultRow(
+ event = event,
+ position = positionOf(index, events.size),
+ onClick = { onEventClick(event) },
+ )
+ }
+ }
+}
+
+@Composable
+private fun SearchResultRow(
+ event: EventInstance,
+ position: Position,
+ onClick: () -> Unit,
+) {
+ val dark = isSystemInDarkTheme()
+ GroupedRow(
+ title = event.title,
+ summary = searchSummary(event),
+ position = position,
+ minHeight = 64.dp,
+ leading = {
+ Box(
+ modifier = Modifier
+ .size(width = 6.dp, height = 36.dp)
+ .clip(RoundedCornerShape(3.dp))
+ .background(pastelize(event.color, dark)),
+ )
+ },
+ onClick = onClick,
+ )
+}
+
+/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
+@Composable
+private fun searchSummary(event: EventInstance): String {
+ val locale = currentLocale()
+ val zone = remember { ZoneId.systemDefault() }
+ val start = remember(event.start, zone) {
+ JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
+ }
+ val dateText = remember(locale) {
+ DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
+ }.format(start)
+ val timeText = if (event.isAllDay) {
+ stringResource(R.string.event_detail_all_day)
+ } else {
+ remember(locale) {
+ DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
+ }.format(start)
+ }
+ val base = "$dateText · $timeText"
+ return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
+}
+
+@Composable
+private fun SearchMessage(
+ icon: ImageVector?,
+ text: String,
+) {
+ Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(32.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (icon != null) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(48.dp),
+ )
+ Spacer(Modifier.height(16.dp))
+ }
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt
new file mode 100644
index 0000000..7bb8ed9
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt
@@ -0,0 +1,77 @@
+package de.jeanlucmakiola.calendula.ui.search
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
+import de.jeanlucmakiola.calendula.data.di.IoDispatcher
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
+import kotlinx.coroutines.flow.stateIn
+import kotlin.time.Clock
+import javax.inject.Inject
+
+/** Shortest query that triggers a search; one character matches almost everything. */
+private const val MIN_QUERY_LENGTH = 2
+
+sealed interface SearchUiState {
+ /** No query yet, or one too short to search — show the prompt. */
+ data object Idle : SearchUiState
+
+ /** A query ran but matched nothing. */
+ data class Empty(val query: String) : SearchUiState
+
+ /** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
+ data class Results(val events: List) : SearchUiState
+}
+
+@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
+@HiltViewModel
+class SearchViewModel @Inject constructor(
+ private val repository: CalendarRepository,
+ @IoDispatcher private val io: CoroutineDispatcher,
+) : ViewModel() {
+
+ private val _query = MutableStateFlow("")
+ val query: StateFlow = _query.asStateFlow()
+
+ val state: StateFlow = _query
+ .debounce(250L)
+ .map { it.trim() }
+ .distinctUntilChanged()
+ .mapLatest { q ->
+ if (q.length < MIN_QUERY_LENGTH) {
+ SearchUiState.Idle
+ } else {
+ val results = repository.searchEvents(q)
+ if (results.isEmpty()) SearchUiState.Empty(q)
+ else SearchUiState.Results(sortNearestFirst(results))
+ }
+ }
+ .catch { emit(SearchUiState.Idle) }
+ .flowOn(io)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
+
+ fun setQuery(value: String) {
+ _query.value = value
+ }
+
+ /** Soonest upcoming (and ongoing) first, then the most recent past. */
+ private fun sortNearestFirst(events: List): List {
+ val now = Clock.System.now()
+ val (upcoming, past) = events.partition { it.end >= now }
+ return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
index 4d1a06f..47e86ba 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
@@ -1,12 +1,16 @@
package de.jeanlucmakiola.calendula.ui.settings
import android.Manifest
+import android.app.StatusBarManager
+import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
+import android.graphics.drawable.Icon
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
+import androidx.annotation.RequiresApi
import android.text.format.DateFormat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -63,6 +67,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -79,6 +84,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
+import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
@@ -513,9 +519,39 @@ private fun EventFormScreen(
)
},
)
+
+ // One-tap add of the "New event" Quick Settings tile. The system prompt
+ // is API 33+; on older versions the tile is still addable manually from
+ // the QS editor, so the row simply doesn't appear there.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Spacer(Modifier.height(24.dp))
+ val context = LocalContext.current
+ GroupedRow(
+ title = stringResource(R.string.settings_qs_tile),
+ summary = stringResource(R.string.settings_qs_tile_hint),
+ position = Position.Alone,
+ onClick = { requestAddQsTile(context) },
+ )
+ }
}
}
+/**
+ * Ask the system to add the "New event" Quick Settings tile (API 33+). The OS
+ * shows its own confirmation dialog and handles the already-added case, so no
+ * result handling is needed here.
+ */
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+private fun requestAddQsTile(context: Context) {
+ val statusBar = context.getSystemService(StatusBarManager::class.java) ?: return
+ statusBar.requestAddTileService(
+ ComponentName(context, NewEventTileService::class.java),
+ context.getString(R.string.qs_tile_new_event_label),
+ Icon.createWithResource(context, R.drawable.ic_qs_new_event),
+ context.mainExecutor,
+ ) { /* result code unused — the system surfaces its own feedback */ }
+}
+
/**
* Reminder-notifications toggle (v1.4), mirroring the onboarding step.
* Turning it on re-requests `POST_NOTIFICATIONS` when missing (API 33+) —
@@ -546,6 +582,7 @@ private fun NotificationsScreen(
var showDefaultReminder by remember { mutableStateOf(false) }
var showAllDayReminder by remember { mutableStateOf(false) }
var showAllDayReminderTime by remember { mutableStateOf(false) }
+ var showSnooze by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf(null) }
var expandedCalendars by remember { mutableStateOf(emptySet()) }
@@ -666,8 +703,27 @@ private fun NotificationsScreen(
},
onClick = { openBatteryOptimizationSettings(context) },
)
+
+ // Snooze: how long the notification's "Snooze" action defers a reminder.
+ Spacer(Modifier.height(24.dp))
+ GroupedRow(
+ title = stringResource(R.string.settings_snooze_duration),
+ summary = snoozeDurationLabel(state.snoozeMinutes),
+ position = Position.Alone,
+ onClick = { showSnooze = true },
+ )
}
+ if (showSnooze) {
+ OptionPicker(
+ title = stringResource(R.string.settings_snooze_duration),
+ options = SNOOZE_PRESETS,
+ selected = state.snoozeMinutes,
+ label = { snoozeDurationLabel(it) },
+ onSelect = { viewModel.setSnoozeMinutes(it) },
+ onDismiss = { showSnooze = false },
+ )
+ }
if (showDefaultReminder) {
ReminderDefaultPicker(
title = stringResource(R.string.settings_default_reminder),
@@ -792,6 +848,18 @@ private fun openBatteryOptimizationSettings(context: Context) {
*/
private val ALLDAY_REMINDER_PRESETS = listOf(0, 1_440, 2_880, 10_080)
+/** Snooze delays offered for the notification "Snooze" action, in minutes. */
+private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
+
+/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
+@Composable
+private fun snoozeDurationLabel(minutes: Int): String =
+ if (minutes % 60 == 0) {
+ pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
+ } else {
+ pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
+ }
+
/** A minute-of-day formatted in the device's 12/24-hour convention (e.g. "09:00"). */
private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
val time = Calendar.getInstance().apply {
@@ -872,6 +940,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/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
index 245c296..5d88985 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt
@@ -30,6 +30,8 @@ data class SettingsUiState(
val defaultAllDayReminderMinutes: Int? = null,
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
+ /** How long the notification "Snooze" action defers a reminder; default 10 min. */
+ val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
/**
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
* calendar present in the map overrides the global default (null value = no
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
index 5ebeb2b..eead6f9 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
@@ -59,8 +59,11 @@ class SettingsViewModel @Inject constructor(
prefs.defaultReminderMinutes,
prefs.defaultAllDayReminderMinutes,
prefs.allDayReminderTimeMinutes,
- ) { allowColor, defaultReminder, allDayReminder, allDayReminderTime ->
- ReminderDefaults(allowColor, defaultReminder, allDayReminder, allDayReminderTime)
+ prefs.snoozeMinutes,
+ ) { allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze ->
+ ReminderDefaults(
+ allowColor, defaultReminder, allDayReminder, allDayReminderTime, snooze,
+ )
},
combine(
prefs.perCalendarReminderOverride,
@@ -75,6 +78,7 @@ class SettingsViewModel @Inject constructor(
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
allDayReminderTimeMinutes = defaults.allDayReminderTime,
+ snoozeMinutes = defaults.snoozeMinutes,
perCalendarReminderOverride = overrides.timed,
perCalendarAllDayReminderOverride = overrides.allDay,
writableCalendars = overrides.calendars,
@@ -90,6 +94,7 @@ class SettingsViewModel @Inject constructor(
val defaultReminder: Int?,
val allDayReminder: Int?,
val allDayReminderTime: Int,
+ val snoozeMinutes: Int,
)
private data class ReminderOverrides(
@@ -130,6 +135,10 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setAllDayReminderTimeMinutes(minutesOfDay) }
}
+ fun setSnoozeMinutes(minutes: Int) {
+ viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
+ }
+
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
}
diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt
index 3e3660f..66f9aeb 100644
--- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt
+++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt
@@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
@@ -76,6 +77,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
+import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.currentLocale
@@ -113,6 +115,7 @@ fun WeekScreen(
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
+ onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
@@ -193,6 +196,7 @@ fun WeekScreen(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onOpenDrawer = { scope.launch { drawerState.open() } },
+ onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,
)
},
@@ -351,6 +355,7 @@ private fun WeekTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
+ onOpenSearch: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
TopAppBar(
@@ -369,6 +374,12 @@ private fun WeekTopBar(
}
},
actions = {
+ IconButton(onClick = onOpenSearch) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(R.string.search_action),
+ )
+ }
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
@@ -610,6 +621,7 @@ private fun Timeline(
blocks = state.timedByDay[day].orEmpty(),
dark = dark,
date = day,
+ today = state.today,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
modifier = Modifier
@@ -628,6 +640,7 @@ private fun DayColumnCard(
blocks: List,
dark: Boolean,
date: LocalDate,
+ today: LocalDate,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
@@ -671,6 +684,10 @@ private fun DayColumnCard(
.padding(horizontal = 1.dp),
)
}
+ // Current-time line, on top of the events, only on today's column.
+ if (date == today) {
+ NowLine(date = date, hourHeight = HOUR_HEIGHT)
+ }
}
}
}
diff --git a/app/src/main/res/drawable/ic_notification_dismiss.xml b/app/src/main/res/drawable/ic_notification_dismiss.xml
new file mode 100644
index 0000000..81973d9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification_dismiss.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notification_snooze.xml b/app/src/main/res/drawable/ic_notification_snooze.xml
new file mode 100644
index 0000000..478352a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification_snooze.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_qs_new_event.xml b/app/src/main/res/drawable/ic_qs_new_event.xml
new file mode 100644
index 0000000..df0f3fb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qs_new_event.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 4ec4c11..42e7f52 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -77,6 +77,16 @@
Hinzufügen
Erinnerung hinzufügen
Erinnerung entfernen
+ Gäste
+ Gast hinzufügen
+ Gast per E-Mail hinzufügen…
+ Aus Kontakten hinzufügen
+ Adresse aus Kontakten wählen
+ 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
@@ -177,6 +187,15 @@
- %d Woche vorher
- %d Wochen vorher
+
+
+ - %d Minute
+ - %d Minuten
+
+
+ - %d Stunde
+ - %d Stunden
+
(Ohne Titel)
@@ -194,6 +213,8 @@
Der Schalter liegt in den Einstellungen unter Benachrichtigungen.
Erinnerungen einschalten
Später
+ Schlummern
+ Verwerfen
Monat
@@ -212,6 +233,14 @@
Nichts geplant
Anstehende Termine erscheinen hier.
+
+ Suchen
+ Termine suchen
+ Zurück
+ Löschen
+ Durchsuche deine Termine nach Titel, Ort oder Notizen.
+ Keine Termine passen zu „%1$s“.
+
Anstehend
Calendula Agenda
@@ -227,6 +256,11 @@
Neuer Termin
Neuen Termin erstellen
+
+ Neuer Termin
+ Schnelleinstellungen-Kachel hinzufügen
+ Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.
+
Kalender
@@ -265,6 +299,7 @@
Zuverlässige Zustellung
Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.
Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.
+ Schlummerdauer
Kalender
Kalender verwalten
Lokale Kalender anlegen, synchronisierte verwalten
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7801091..0a874c0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -78,6 +78,16 @@
Add
Add reminder
Remove reminder
+ Guests
+ Add guest
+ Add a guest by email…
+ Add from contacts
+ Pick address 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
@@ -178,6 +188,15 @@
- %d week before
- %d weeks before
+
+
+ - %d minute
+ - %d minutes
+
+
+ - %d hour
+ - %d hours
+
(No title)
@@ -195,6 +214,8 @@
The switch lives in Settings, under Notifications.
Turn on reminders
Not now
+ Snooze
+ Dismiss
Month
@@ -213,6 +234,14 @@
Nothing scheduled
Upcoming events will show up here.
+
+ Search
+ Search events
+ Back
+ Clear
+ Search your events by title, location or notes.
+ No events match “%1$s”.
+
Upcoming
Calendula agenda
@@ -262,6 +291,7 @@
Reliable delivery
Android may delay reminders to save battery. Exempt Calendula so they arrive on time.
Exempt from battery optimisation — reminders arrive on time.
+ Snooze duration
Calendars
Manage calendars
Create local calendars; manage synced ones
@@ -340,6 +370,11 @@
New event
Create a new event
+
+ New event
+ Add Quick Settings tile
+ Add a “New event” tile to the Quick Settings panel.
+
https://gitea.jeanlucmakiola.de/makiolaj/calendula
https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE
@@ -355,6 +390,6 @@
Couldn\'t open the issue tracker. The report is on your clipboard.
Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n
_(The report was too long for this link — paste it from your clipboard here.)_
- https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new
- https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new/choose
+ https://codeberg.org/jlmakiola/calendula/issues/new
+ https://codeberg.org/jlmakiola/calendula/issues/new/choose
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
index f6a447b..900c9bc 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt
@@ -16,6 +16,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var calendarsResult: List = emptyList()
var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() }
+ var searchResult: (String) -> List = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List = { emptyList() }
var exportableEventsResult: List = emptyList()
@@ -51,6 +52,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun calendars(): List = calendarsResult
override fun instances(beginMillis: Long, endMillis: Long): List =
instancesResult(beginMillis, endMillis)
+ override fun searchEvents(query: String): List = searchResult(query)
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List =
eventColorPaletteResult(calendarId)
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
index 50ab6d8..dee5554 100644
--- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
@@ -233,6 +233,23 @@ class SettingsPrefsTest {
assertThat(prefs.allDayReminderTimeMinutes.first()).isEqualTo(0)
}
+ @Test
+ fun `snooze duration defaults to 10 minutes`(@TempDir tempDir: Path) = runTest {
+ val prefs = SettingsPrefs(newDataStore(tempDir))
+ assertThat(prefs.snoozeMinutes.first()).isEqualTo(10)
+ }
+
+ @Test
+ fun `snooze duration round-trips and clamps to at least one minute`(
+ @TempDir tempDir: Path,
+ ) = runTest {
+ val prefs = SettingsPrefs(newDataStore(tempDir))
+ prefs.setSnoozeMinutes(30)
+ assertThat(prefs.snoozeMinutes.first()).isEqualTo(30)
+ prefs.setSnoozeMinutes(0)
+ assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
+ }
+
@Test
fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
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
diff --git a/fastlane/metadata/android/en-US/changelogs/20800.txt b/fastlane/metadata/android/en-US/changelogs/20800.txt
new file mode 100644
index 0000000..3d896a3
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/20800.txt
@@ -0,0 +1,38 @@
+### Added
+- Find events fast. A search button in the top bar of every calendar view opens
+ a search box — type a couple of letters and matching events (by title,
+ location or description) appear, soonest first with past events below. Tap a
+ result to open it. Search covers your whole calendar, not just what's on
+ screen, and skips calendars you've hidden. A recurring event shows its next
+ occurrence rather than the date the series first started.
+- A current-time line in the day and week views. A thin coloured line marks the
+ present moment across today's column, so you can see at a glance where you are
+ in the day. It updates every minute and only appears when today is in view.
+- Add and remove event guests. The create/edit form now has a Guests section:
+ add people by email or pick them from your contacts, mark each as required or
+ optional, and remove them. Calendula never sends invitations itself (it has no
+ internet access) — it only records the guests; if the event lives on a synced
+ account, that account may email them when it syncs, and on a local calendar no
+ one is notified. The form tells you which applies. Picking a guest from
+ contacts needs no contacts permission.
+- Pick a location from your contacts. A contacts button beside the location
+ field drops a contact's address straight into an event — handy for a meeting
+ at someone's home or office. Like the guest picker, it needs no contacts
+ permission.
+- A "New event" Quick Settings tile. Add it to your quick settings to jump
+ straight into the new-event form from anywhere. Settings → New event has a
+ one-tap button to add the tile (Android 13+); on older versions you can add it
+ from the quick-settings editor.
+- Snooze and dismiss buttons on reminder notifications. Dismiss clears the
+ reminder; snooze hides it and brings it back after a delay you pick in
+ Settings → Notifications (5 to 60 minutes, default 10). Android's calendar
+ system won't re-post a reminder on its own, so Calendula schedules an exact
+ alarm to bring a snoozed one back on time.
+
+### Changed
+- Event details now show each guest's email beneath their name, instead of only
+ when no name is available.
+- Crash and problem reports now open on the project's public Codeberg tracker,
+ where anyone can register and file an issue. Nothing is sent automatically —
+ you still review the report and submit it yourself in the browser.
+