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. +