Compare commits
37 Commits
v2.7.5
...
a69b161178
| Author | SHA1 | Date | |
|---|---|---|---|
| a69b161178 | |||
| 024deadf78 | |||
| 599c3c03e7 | |||
| 5d48c90408 | |||
| c6d15d0118 | |||
| 79e083b4d4 | |||
| 119a8afe8e | |||
| b537e143f8 | |||
| faf2f27eda | |||
| 3d8e3ca69e | |||
| 3b5c0be765 | |||
| 013efef29e | |||
| 5457a34282 | |||
| 82e93fda4c | |||
| 871cc34cab | |||
| 5b99da32b0 | |||
| b8a7191fbe | |||
| 6b0eb48056 | |||
| 572a4734ea | |||
| 7f8a9069c0 | |||
| 22c4eded96 | |||
| 9839f9cd38 | |||
| c0e27133b4 | |||
| a4e0ec3fde | |||
| ac17343747 | |||
| b0f34ff18a | |||
| ab8a9e3401 | |||
| 92ec718b28 | |||
| 4ad1c09f8f | |||
| 5e8cb686e9 | |||
| 62ebd48e3c | |||
| c3dbcca989 | |||
| 37452be3bd | |||
| 006cce96a9 | |||
| 88d770bebe | |||
| b6bee55fc6 | |||
| 7ab277b434 |
@@ -236,22 +236,34 @@ pass on the existing controls; new toggles ride in with their own features.
|
||||
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||
default reminder **+ per-calendar override**, bundled with battery-exemption
|
||||
hardening. Full sketch in "Reminders — defaults & delivery reliability" below.
|
||||
10. **The `.ics` engine — export + import** *(in progress → v2.7)* — one
|
||||
10. **The `.ics` engine — export + import** *(shipped v2.7.0, 2026-06-18)* — one
|
||||
hand-rolled serializer/parser (zero deps, stays on `kotlinx-datetime`),
|
||||
four surfaces: single-event share + whole-calendar backup (export),
|
||||
open-`.ics`→form + whole-calendar restore (import). Closes the
|
||||
device-local-calendar data-loss gap (#10/#11 merged here). Built as **two
|
||||
sequential branches in one release**: `feat/ics-export` (write side +
|
||||
UID-on-create precursor) then `feat/ics-import` (parser, restore, dedup).
|
||||
Import is liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` /
|
||||
`RECURRENCE-ID` it can't model. Timezone rule: all-day `VALUE=DATE`,
|
||||
non-recurring timed UTC `Z`, recurring timed `TZID`-labelled from the stored
|
||||
`EVENT_TIMEZONE` (no `VTIMEZONE` blocks; resolved against the OS tz DB on
|
||||
import). Plan: `docs/superpowers/plans/2026-06-18-05-ics-export.md`.
|
||||
11. **Snooze / dismiss notification actions** *(next, after v2.7)* — follows the
|
||||
`.ics` work; inherits v2.6's deferred exact-alarm/WorkManager decision (snooze
|
||||
must re-fire an alarm).
|
||||
12. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
||||
four surfaces all shipped: single-event share + whole-calendar backup
|
||||
(export), open-`.ics`→form + whole-calendar bulk import (import). Closed the
|
||||
device-local-calendar data-loss gap (#10/#11 merged here). Built as two
|
||||
sequential branches: `feat/ics-export` (write side + UID-on-create precursor)
|
||||
then `feat/ics-import` (parser, restore, dedup by UID). Import is
|
||||
liberal-in/strict-out: skip-and-report foreign `VTIMEZONE` / `RECURRENCE-ID`
|
||||
/ guest lists it can't model. Plans:
|
||||
`docs/superpowers/plans/2026-06-18-05-ics-export.md` + `…-06-ics-import.md`.
|
||||
11. **Snooze / dismiss notification actions** *(merged into release/v2.8.0)* —
|
||||
followed the `.ics` work; inherits v2.6's deferred exact-alarm/WorkManager
|
||||
decision (snooze must re-fire an alarm).
|
||||
|
||||
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
|
||||
12. **Attendee editing** *(next — promoted from the gated "Locations & People"
|
||||
bucket, owner says high-importance 2026-06-22)* — attendees are already
|
||||
*read* (queried, mapped, shown on the detail screen since v0.6) but the
|
||||
event form can't *write* them, the last big read-only gap in the event
|
||||
model. Add an attendees section to `EventEditScreen` / `EventForm`: add by
|
||||
typed email (contact-picker entry can follow later), edit/remove rows, set
|
||||
role (required / optional) — writing `CalendarContract.Attendees` rows on
|
||||
insert + dirty-checked update, mirroring the reminders-diff pattern.
|
||||
**Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter
|
||||
*invitation* behavior that differs by backend (Google auto-emails invites;
|
||||
DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) —
|
||||
decide and document what Calendula promises before building. Full sketch in
|
||||
"Attendee editing" under Locations & People below.
|
||||
|
||||
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||
@@ -263,11 +275,13 @@ pass on the existing controls; new toggles ride in with their own features.
|
||||
2026-06-17; cheap but low value, pick up only if asked
|
||||
|
||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
||||
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
||||
full-text search. Pulled in opportunistically, not sequenced.
|
||||
|
||||
Debatable calls worth a second look: whether **local-calendar backup (#10)**
|
||||
should lead Tier 4 outright (it's a silent data-loss risk, not a feature);
|
||||
whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
||||
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
||||
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected).
|
||||
**Next committed work is Tier 5 #12 — attendee editing** (the last read-only gap
|
||||
in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
|
||||
(quick-settings tile, now-line, week numbers in month, full-text search).
|
||||
|
||||
## Navigation & views
|
||||
|
||||
@@ -288,8 +302,6 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
||||
|
||||
## Event editing & creation
|
||||
|
||||
- Drag & drop rescheduling in day/week (recurring drops reuse the scope
|
||||
dialog) — big-ticket, own slice
|
||||
- Duplicate event (detail action → prefilled create form)
|
||||
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
|
||||
*(next)* — chosen to follow the in-progress tap-to-create + calendar
|
||||
@@ -319,13 +331,11 @@ whether drag-drop (#12) jumps ahead given its daily-driver impact.
|
||||
go/no-go gate as the OSM/INTERNET item below.
|
||||
- Move event to another calendar (copy+delete model with a consequences
|
||||
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
||||
- **Local-calendar backup / export** *(Tier 4 #10)* — device-only
|
||||
(`ACCOUNT_TYPE_LOCAL`) calendars are first-class in Calendula but have **no
|
||||
sync and therefore no backup**: a lost/wiped phone destroys them permanently.
|
||||
Whole-calendar `.ics` (VCALENDAR) export to a user-chosen file (SAF), plus
|
||||
restore-on-import that recreates events into a chosen local calendar. Reuses
|
||||
the .ics serializer from the single-event share work; the restore path reuses
|
||||
the import parser. A data-integrity obligation, not a feature.
|
||||
- ~~**Local-calendar backup / export** *(Tier 4 #10)*~~ **shipped v2.7.0** —
|
||||
device-only (`ACCOUNT_TYPE_LOCAL`) calendars had no sync and therefore no
|
||||
backup. Settings → Calendars → Export writes every event to a user-chosen
|
||||
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
|
||||
UID). Closed the silent data-loss gap.
|
||||
|
||||
## Reminders — defaults & delivery reliability *(implemented 2026-06-17, `feat/default-reminders` — pending on-device review)*
|
||||
|
||||
@@ -419,9 +429,11 @@ it directly undermines the feature's premise, so it rides in here.
|
||||
|
||||
## Sharing & interop
|
||||
|
||||
- Share event as .ics + open/receive .ics into a prefilled create form
|
||||
(front-runs the import below)
|
||||
- ICS file import (drag-and-drop) *(was v3.0, optional)*
|
||||
- ~~Share event as .ics + open/receive .ics into a prefilled create form~~
|
||||
**shipped v2.7.0** — single-event share from detail; opening an `.ics` with one
|
||||
event prefills the create form, many events opens a bulk import (dedup by UID)
|
||||
- ~~ICS file import~~ **shipped v2.7.0** — covered by the open/receive `.ics`
|
||||
flow above (single → form, many → bulk import)
|
||||
|
||||
## Platform & launchers
|
||||
|
||||
@@ -455,9 +467,57 @@ in detail yet:
|
||||
trade-off is an explicit go/no-go decision before any work starts.
|
||||
- **Inline contact suggestions** while typing (needs READ_CONTACTS) — only
|
||||
if the picker proves clunky.
|
||||
- **Attendee editing / invites from contacts** — own milestone; writing
|
||||
`Attendees` rows touches sync-adapter invitation behavior (Google vs
|
||||
DAVx5 differ).
|
||||
- **Attendee editing** *(promoted out of this gated bucket 2026-06-22 — now
|
||||
Tier 5 #12, high-importance; the no-permission typed-email path is not an
|
||||
INTERNET/contacts call)*. See the "Attendee editing" sketch below.
|
||||
|
||||
### Attendee editing *(Tier 5 #12, opened 2026-06-22)*
|
||||
|
||||
The last read-only gap in the event model: attendees are read & shown on the
|
||||
detail screen (since v0.6) but the form can't write them. Make guests editable.
|
||||
|
||||
- **Read side already done:** `Attendee` domain model + status/relationship/type
|
||||
enums, `queryAttendees` + `EventDetailMapper.toAttendee` (with tests), and the
|
||||
attendees `DetailCard` + `AttendeeRow` in `EventDetailScreen`. Nothing to add
|
||||
there.
|
||||
- **Write side — SHIPPED (`feat/attendee-editing`, commit b0f34ff, 2026-06-22):**
|
||||
`attendees` on `EventForm`; a Guests section in `EventEditScreen` rendered as an
|
||||
inline grouped list — each guest a tonal card (avatar, name/email, tappable
|
||||
Required/Optional role chip, remove), the trailing card an inline email field
|
||||
(type → Done commits). Persisted by `reconcileAttendees` diffing the provider's
|
||||
`CalendarContract.Attendees` rows on insert + dirty-checked update (new guests =
|
||||
`RELATIONSHIP_ATTENDEE` / `STATUS_INVITED`; kept rows keep their status, only the
|
||||
required/optional type updates; organizer/resource/no-email rows untouched).
|
||||
Reminders restyled to the same grouped-list pattern in passing. Needs only the
|
||||
existing `WRITE_CALENDAR` — no new permission.
|
||||
- **Name on manual add — SHIPPED via a contact picker** (2026-06-22). The inline
|
||||
field is email-only (the quick path); the add row also has a **"from contacts"
|
||||
button**: `ACTION_PICK` on `ContactsContract.CommonDataKinds.Email.CONTENT_URI` →
|
||||
the system Contacts app returns a URI to the picked email row, queried for
|
||||
`Email.ADDRESS` **and** the contact `DISPLAY_NAME`, so a picked guest gets both
|
||||
email and name in one tap. The result Intent grants temporary read access, so
|
||||
**no `READ_CONTACTS` permission** — one-shot and user-driven, same no-permission
|
||||
mechanism as the location address picker above. Self/organizer rows stay
|
||||
non-editable.
|
||||
- **Invitation behavior — DECIDED 2026-06-22: record-only, all writable
|
||||
calendars.** Calendula has no INTERNET and never sends an invitation itself; it
|
||||
only writes `Attendees` rows. Whether a guest is notified is decided downstream:
|
||||
local calendars notify no one (no sync); CalDAV/DAVx5 PUTs the `ATTENDEE` lines
|
||||
and the *server* decides iMIP delivery; Google's sync adapter pushes the change
|
||||
and Google decides (third-party attendee writes are historically unreliable
|
||||
there). Editing is allowed on **any writable calendar** — not gated to local.
|
||||
- **Honest, backend-aware copy is mandatory** (this is the whole point of the
|
||||
decision): on a synced calendar show "Calendula doesn't send invitations —
|
||||
your calendar account may email guests when it syncs"; on a local calendar
|
||||
show "Stored on this device. No one is notified."
|
||||
- Calendula must **not fabricate an ORGANIZER** or otherwise fake scheduling
|
||||
state to coax a send — it writes the guest list faithfully and leaves
|
||||
scheduling entirely to the backend.
|
||||
- The optional "send an .ics invite via your email app" delegate (`ACTION_SEND`,
|
||||
still no INTERNET) was considered and **deferred** — revisit only if users ask
|
||||
to notify guests explicitly.
|
||||
- **Out of scope (for now):** RSVP/your-own-response editing, free/busy lookups,
|
||||
resource booking — all carry server round-trips or richer sync semantics.
|
||||
|
||||
## Consciously rejected
|
||||
|
||||
@@ -465,3 +525,5 @@ in detail yet:
|
||||
- Natural-language quick entry (high effort, locale-fragile; the prefilled
|
||||
form already covers fast entry)
|
||||
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
|
||||
- Drag & drop rescheduling in day/week — **rejected** (owner decision,
|
||||
reaffirmed 2026-06-22): not wanted. Rescheduling stays via the edit form.
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
# Calendula — Current State
|
||||
|
||||
*Last updated: 2026-06-17*
|
||||
*Last updated: 2026-06-22*
|
||||
|
||||
## Status
|
||||
|
||||
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
|
||||
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
|
||||
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
|
||||
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
|
||||
the calendar manager and the navigation drawer) both shipped 2026-06-16;
|
||||
v2.4.0 (per-event colors) and v2.5.0 (jump-to-date, Agenda view, home-screen
|
||||
agenda + month widgets, and a "New event" launcher shortcut) shipped
|
||||
2026-06-17. The backlog is now organised by theme in `ROADMAP.md`.
|
||||
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
|
||||
**Phase:** post-2.x theme-based backlog work (organised in `ROADMAP.md`).
|
||||
**Latest released tag: v2.7.5.** The whole Tier 4 (reliability/data-safety/
|
||||
interop) arc is now done or in flight:
|
||||
- v2.4.0 per-event colors (2026-06-17)
|
||||
- v2.5.0 jump-to-date, Agenda view, agenda + month home-screen widgets, "New
|
||||
event" launcher shortcut (2026-06-17)
|
||||
- v2.6.0 default reminders (global + per-calendar override, all-day default,
|
||||
battery-exemption row) + system per-app language (2026-06-18)
|
||||
- v2.7.0 **`.ics` engine** — single-event share, local-calendar backup export,
|
||||
open/receive `.ics` (single → form, many → bulk import, dedup by UID)
|
||||
(2026-06-18)
|
||||
- v2.7.1–v2.7.5 — crash-reporting + F-Droid reproducible-build hardening + fixes
|
||||
|
||||
**Next release `release/v2.8.0` (integration branch, not yet cut to main):**
|
||||
holds crash reports via the public Codeberg tracker (MR !27) + reminder
|
||||
snooze/dismiss notification actions (MR !28). Version bump to 2.8.0 happens at
|
||||
release-cut.
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -120,11 +130,48 @@ agenda + month widgets, and a "New event" launcher shortcut) shipped
|
||||
sync" warning on the picker and in Settings. Color writes flow through
|
||||
insert / dirty-checked update / occurrence-exception; mapper + form tests.
|
||||
|
||||
- [x] v2.5 (shipped 2026-06-17) — Agenda view (4th top-level view),
|
||||
jump-to-date drawer date picker, two home-screen widgets (scrolling
|
||||
"Upcoming" agenda + month grid), and a "New event" launcher long-press
|
||||
shortcut
|
||||
|
||||
- [x] v2.6 (shipped 2026-06-18) — default reminders: global timed default +
|
||||
separate all-day default + per-calendar override (timed), applied on create
|
||||
with dirty-flag handling; three pickers + override list in Settings →
|
||||
Notifications; battery-optimisation exemption row (status + system deep-link,
|
||||
no new permission). Plus system per-app language (Android 13+) and an
|
||||
immediate-effect fix for the in-app language picker
|
||||
|
||||
- [x] v2.7 (shipped 2026-06-18) — the `.ics` engine: share a single event as
|
||||
`.ics` from the detail screen; back up local calendars (Settings → Calendars
|
||||
→ Export) to a SAF file; open/receive an `.ics` — one event prefills the
|
||||
create form, many events open a bulk import into a chosen calendar (dedup by
|
||||
UID, skip-and-report unrepresentable VTIMEZONE / RECURRENCE-ID / guests).
|
||||
Hand-rolled serializer/parser, zero deps. Plus all-day single-day UTC fix and
|
||||
a widget R8 keep-rule crash fix
|
||||
|
||||
- [x] v2.7.1–v2.7.5 (2026-06-21) — launch crash fix (listener before grant),
|
||||
user-controlled crash reporting, widget loading-spinner R8 keep rule, and
|
||||
F-Droid reproducible-build cleanups for the official repo
|
||||
|
||||
- [~] release/v2.8.0 (not yet cut) — crash reports via the public Codeberg
|
||||
tracker (MR !27) + reminder snooze/dismiss notification actions (MR !28,
|
||||
snooze self-schedules an exact alarm; primary delivery stays provider-broadcast)
|
||||
|
||||
## Next
|
||||
|
||||
1. Monitor the F-Droid build/publish for the v2.4.0 tag
|
||||
2. Decide the "Locations & People" and "remote calendar create/edit"
|
||||
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
|
||||
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
|
||||
agenda view (strategic, backs a future widget). Full ranked sequence in
|
||||
`ROADMAP.md` → "Near-term sequence".
|
||||
1. Cut **v2.8.0** from `release/v2.8.0` (bump versionName → tag via the
|
||||
merge-driven pipeline) once on-device review signs off
|
||||
2. **Attendee editing** — the committed next feature (Tier 5 #12, high-
|
||||
importance, opened 2026-06-22). Attendees are already read & shown on the
|
||||
detail screen since v0.6; the gap is the write side — make guests editable
|
||||
in `EventEditScreen` / `EventForm` (add by typed email, role, remove),
|
||||
persisted by diffing `CalendarContract.Attendees`. No new permission for the
|
||||
typed-email path. **Invitation behavior DECIDED 2026-06-22: record-only on all
|
||||
writable calendars** — Calendula never sends (no INTERNET); honest backend-aware
|
||||
copy ("your account may email guests when it syncs" on synced calendars, "no one
|
||||
is notified" on local). Full sketch in `ROADMAP.md` → "Attendee editing".
|
||||
3. Then: the two INTERNET go/no-go calls (OSM autocomplete, remote calendar
|
||||
create/edit) and Tier 2/3 leftovers (quick-settings tile, now-line, week
|
||||
numbers in month, full-text search, accessibility pass). Drag-and-drop
|
||||
rescheduling is **rejected**.
|
||||
|
||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -5,7 +5,80 @@ 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.9.0] — 2026-06-25
|
||||
|
||||
### Added
|
||||
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
|
||||
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
|
||||
time you start the app, instead of always opening on Week. Thanks to
|
||||
@devinside for the suggestion ([#1]).
|
||||
- The month widget is now interactive: tap any day to open it, or tap an event to
|
||||
open its details. Previously only the month's prev/next/today controls
|
||||
responded. Thanks to @devinside for spotting this ([#2]).
|
||||
- Predictive back. Swiping back from a screen — an event, the editor, search or
|
||||
settings — now follows your finger, shrinking the screen to preview what's
|
||||
behind so you can see where Back will take you before you let go; slide it back
|
||||
to cancel. The preview needs Android 14 or newer; on older versions Back works
|
||||
as before.
|
||||
- Respect for "Remove animations". If you've turned animations off in your
|
||||
device's Accessibility settings, Calendula now honours it everywhere: motion
|
||||
collapses to a quick fade and the back preview is skipped.
|
||||
|
||||
### Changed
|
||||
- Smoother, more consistent motion throughout. Expanding sections, list updates
|
||||
in search and agenda, and the onboarding screens now animate the same way
|
||||
across the app, instead of some places sliding or growing while others popped
|
||||
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
|
||||
snapping, while paging within a view keeps its slide.
|
||||
|
||||
### Fixed
|
||||
- Home-screen widgets now back out the way you came in. A day or event opened
|
||||
from the Agenda widget keeps you in the agenda context, and from the Month
|
||||
widget in the month context — pressing Back returns there instead of dropping
|
||||
you on the week view. More broadly, the app now keeps a real view history:
|
||||
switching views and drilling into a day are retraced by Back one step at a
|
||||
time, down to your default view before the app exits. Thanks to @devinside for
|
||||
reporting ([#2]).
|
||||
|
||||
## [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
|
||||
|
||||
@@ -534,3 +607,6 @@ automatically, with zero telemetry and no internet permission.
|
||||
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
||||
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
||||
- `.planning/` project-tracking documents
|
||||
|
||||
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
|
||||
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
|
||||
|
||||
@@ -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 = 20900
|
||||
versionName = "2.9.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,17 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<!--
|
||||
Re-fire a snoozed reminder at an exact time (the calendar provider won't —
|
||||
its alert is already fired). USE_EXACT_ALARM is auto-granted to calendar
|
||||
apps on API 33+; SCHEDULE_EXACT_ALARM covers API 31–32 (user-revocable,
|
||||
with an inexact fallback if withheld). F-Droid-clean: no Play allowlisting.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||
@@ -28,6 +39,7 @@
|
||||
<application
|
||||
android:name=".CalendulaApp"
|
||||
android:allowBackup="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -77,6 +89,20 @@
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<!-- Quick Settings tile: a one-tap "New event" shortcut in the QS panel.
|
||||
Exported with BIND_QUICK_SETTINGS_TILE so only the system QS host can
|
||||
bind it; the action mirrors the launcher "New event" shortcut. -->
|
||||
<service
|
||||
android:name=".qs.NewEventTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_qs_new_event"
|
||||
android:label="@string/qs_tile_new_event_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||
no notification itself — a calendar app must (v1.4, Etar model).
|
||||
Exported: the broadcast arrives from the provider's process. -->
|
||||
@@ -91,6 +117,13 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Snooze / dismiss actions on a reminder notification, plus the snooze
|
||||
re-show alarm. Not exported: only our own notification buttons and
|
||||
AlarmManager PendingIntents target it. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.ReminderActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Home-screen widgets (Glance). Exported: the launcher/host binds them. -->
|
||||
<receiver
|
||||
android:name=".widget.agenda.AgendaWidgetReceiver"
|
||||
|
||||
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
@@ -140,18 +141,38 @@ class MainActivity : AppCompatActivity() {
|
||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||
}
|
||||
|
||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
||||
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||
// can't carry typed extras, so the action alone signals create-on-today.
|
||||
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
||||
getStringExtra(EXTRA_DATE_ISO) != null ->
|
||||
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
|
||||
else -> null
|
||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||
val source = sourceViewOrNull()
|
||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||
return when {
|
||||
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||
// can't carry typed extras, so the action alone signals create-on-today.
|
||||
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
||||
// A widget event tap carries both the occurrence and its source view;
|
||||
// reminders (no source) fall through to [detailKeyOrNull] instead.
|
||||
source != null && eventId != -1L -> WidgetNavRequest.OpenEvent(
|
||||
eventId = eventId,
|
||||
beginMillis = getLongExtra(EXTRA_BEGIN_MILLIS, 0L),
|
||||
endMillis = getLongExtra(EXTRA_END_MILLIS, 0L),
|
||||
source = source,
|
||||
)
|
||||
source != null && getStringExtra(EXTRA_DATE_ISO) != null ->
|
||||
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!, source)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The view the launching widget represents, if any (see [EXTRA_SOURCE_VIEW]). */
|
||||
private fun Intent.sourceViewOrNull(): CalendarView? =
|
||||
getStringExtra(EXTRA_SOURCE_VIEW)
|
||||
?.let { name -> CalendarView.entries.firstOrNull { it.name == name } }
|
||||
|
||||
private fun Intent.detailKeyOrNull(): LongArray? {
|
||||
// A widget event tap (source present) is routed through [navRequestOrNull]
|
||||
// so it can also set the base view; only sourceless reminder taps land here.
|
||||
if (sourceViewOrNull() != null) return null
|
||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||
if (eventId == -1L) return null
|
||||
return longArrayOf(
|
||||
@@ -168,6 +189,10 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||
|
||||
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
||||
// in-app back stack in that view; absent for non-widget launches (reminders).
|
||||
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
||||
|
||||
// Fired by the launcher long-press "New event" shortcut (res/xml/
|
||||
// shortcuts.xml hardcodes this string — keep the two in sync).
|
||||
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||
@@ -190,14 +215,35 @@ class MainActivity : AppCompatActivity() {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/** Open the day view anchored on [date] (home-screen widgets). */
|
||||
fun openDateIntent(context: Context, date: LocalDate): Intent =
|
||||
/**
|
||||
* Open the day view anchored on [date], drilled in over the launching
|
||||
* widget's [source] view (home-screen widgets). Backing out of the day
|
||||
* returns to [source], then to the default home view.
|
||||
*/
|
||||
fun openDateIntent(context: Context, date: LocalDate, source: CalendarView): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://date/$date".toUri()
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one occurrence's detail from a widget event tap, rooting the back
|
||||
* stack in the widget's [source] view. Same occurrence-key shape as
|
||||
* [eventDetailIntent]; the [source] extra is what distinguishes a widget
|
||||
* tap (sets the base view) from a reminder tap (leaves it untouched).
|
||||
*/
|
||||
fun openEventIntent(
|
||||
context: Context,
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
endMillis: Long,
|
||||
source: CalendarView,
|
||||
): Intent = eventDetailIntent(context, eventId, beginMillis, endMillis).apply {
|
||||
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||
}
|
||||
|
||||
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -46,6 +47,14 @@ interface CalendarDataSource {
|
||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||
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<EventInstance>
|
||||
|
||||
/**
|
||||
* 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<EventInstance> {
|
||||
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<EventInstance>(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<Long, Long>? {
|
||||
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<Long, Long>? {
|
||||
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<EventAttendee>.distinctByEmail(): List<EventAttendee> {
|
||||
val seen = HashSet<String>()
|
||||
return filter { it.email.isNotBlank() && seen.add(it.key()) }
|
||||
}
|
||||
|
||||
private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues =
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Attendees.EVENT_ID, eventId)
|
||||
put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim())
|
||||
attendee.name.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) }
|
||||
// A plain guest — never the organizer (we don't fabricate scheduling
|
||||
// identity; the backend owns that). Invited = awaiting their reply.
|
||||
put(
|
||||
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE,
|
||||
)
|
||||
put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType())
|
||||
put(
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
|
||||
)
|
||||
}
|
||||
|
||||
private fun EventAttendee.providerType(): Int = if (optional) {
|
||||
CalendarContract.Attendees.TYPE_OPTIONAL
|
||||
} else {
|
||||
CalendarContract.Attendees.TYPE_REQUIRED
|
||||
}
|
||||
|
||||
/** Best-effort attendee inserts (like reminders); emails are never logged. */
|
||||
private fun insertAttendees(eventId: Long, attendees: List<EventAttendee>) {
|
||||
attendees.distinctByEmail().forEach { attendee ->
|
||||
if (resolver.insert(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
attendeeValues(eventId, attendee),
|
||||
) == null
|
||||
) {
|
||||
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class AttendeeRow(
|
||||
val id: Long,
|
||||
val email: String,
|
||||
val relationship: Int,
|
||||
val type: Int,
|
||||
) {
|
||||
val key: String get() = email.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
arrayOf(
|
||||
CalendarContract.Attendees._ID,
|
||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||
),
|
||||
CalendarContract.Attendees.EVENT_ID + " = ?",
|
||||
arrayOf(eventId.toString()),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
while (c.moveToNext()) {
|
||||
add(
|
||||
AttendeeRow(
|
||||
id = c.getLong(0),
|
||||
email = c.getString(1).orEmpty(),
|
||||
relationship = c.getInt(2),
|
||||
type = c.getInt(3),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
/**
|
||||
* Make the event's *editable* guest rows match [attendees]. Only rows we
|
||||
* own — has an email, not the organizer, not a resource — are reconciled;
|
||||
* everything else the backend put there is left untouched. Matched rows keep
|
||||
* their response status (only the required/optional type is updated if it
|
||||
* changed); dropped rows are deleted; new guests inserted. Calendula sends
|
||||
* nothing — the backend decides if anyone is notified on sync.
|
||||
*/
|
||||
private fun reconcileAttendees(eventId: Long, attendees: List<EventAttendee>) {
|
||||
val target = attendees.distinctByEmail()
|
||||
val targetByKey = target.associateBy { it.key() }
|
||||
val editable = queryAttendeeRows(eventId).filter {
|
||||
it.email.isNotBlank() &&
|
||||
it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER &&
|
||||
it.type != CalendarContract.Attendees.TYPE_RESOURCE
|
||||
}
|
||||
val editableKeys = editable.map { it.key }.toSet()
|
||||
editable.forEach { row ->
|
||||
val want = targetByKey[row.key]
|
||||
if (want == null) {
|
||||
resolver.delete(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
CalendarContract.Attendees._ID + " = ?",
|
||||
arrayOf(row.id.toString()),
|
||||
)
|
||||
} else if (row.type != want.providerType()) {
|
||||
resolver.update(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
ContentValues().apply {
|
||||
put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType())
|
||||
},
|
||||
CalendarContract.Attendees._ID + " = ?",
|
||||
arrayOf(row.id.toString()),
|
||||
)
|
||||
}
|
||||
}
|
||||
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
|
||||
if (resolver.insert(
|
||||
CalendarContract.Attendees.CONTENT_URI,
|
||||
attendeeValues(eventId, attendee),
|
||||
) == null
|
||||
) {
|
||||
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||
ContentValues().also { cv ->
|
||||
forEach { (column, value) ->
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ interface CalendarRepository {
|
||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||
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<EventInstance>
|
||||
|
||||
/**
|
||||
* The event-colour palette a calendar's account publishes; empty when it
|
||||
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
||||
|
||||
@@ -80,6 +80,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
}
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = 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<EventColorOption> =
|
||||
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
||||
|
||||
|
||||
@@ -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<String> = 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<String> = arrayOf(
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -69,6 +70,20 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||
* the historical hard-coded startup view — so existing users see no change
|
||||
* until they pick another. Also the bottom of the in-app view back stack, so
|
||||
* back from any other view returns here before exiting.
|
||||
*/
|
||||
val defaultView: Flow<CalendarView> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_VIEW_KEY].toEnum(CalendarView.Week)
|
||||
}
|
||||
|
||||
suspend fun setDefaultView(view: CalendarView) {
|
||||
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional event-form fields shown by default (the rest hide behind
|
||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||
@@ -177,6 +192,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<Int> = 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
|
||||
@@ -230,6 +259,7 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
@@ -243,6 +273,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 =
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<AlarmManager>() ?: 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()
|
||||
}
|
||||
@@ -41,6 +41,27 @@ data class EventForm(
|
||||
*/
|
||||
val colorKey: String? = null,
|
||||
val color: Int? = null,
|
||||
/**
|
||||
* Guests the user has added/kept on the event. Calendula only writes these
|
||||
* `Attendees` rows — it has no INTERNET and never sends an invitation
|
||||
* itself; whether a guest is notified is decided downstream by the
|
||||
* calendar's backend (local: no one; CalDAV/Google: the server/account).
|
||||
* Read-only rows the form doesn't model (the organizer, resources) are
|
||||
* preserved by the data layer, not carried here.
|
||||
*/
|
||||
val attendees: List<EventAttendee> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One editable guest: the user controls the [email] (the identity we dedup and
|
||||
* write on), an optional display [name], and whether they're [optional] rather
|
||||
* than required. Response status and the organizer/resource distinction are not
|
||||
* user-editable, so they aren't modelled here.
|
||||
*/
|
||||
data class EventAttendee(
|
||||
val email: String,
|
||||
val name: String = "",
|
||||
val optional: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -55,6 +76,7 @@ enum class EventFormField {
|
||||
Availability,
|
||||
Visibility,
|
||||
Color,
|
||||
Attendees,
|
||||
}
|
||||
|
||||
enum class EventFormProblem {
|
||||
@@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
// calendar's colour.
|
||||
colorKey = eventColorKey,
|
||||
color = eventColor,
|
||||
// Only editable guests ride in the form: drop the organizer and
|
||||
// resource rows (not user-editable) and any without an email (we key
|
||||
// edits and dedup on the address). The data layer preserves the rows we
|
||||
// don't carry here, so they're never clobbered on save.
|
||||
attendees = attendees
|
||||
.filter {
|
||||
it.relationship != AttendeeRelationship.Organizer &&
|
||||
it.type != AttendeeType.Resource
|
||||
}
|
||||
.mapNotNull { a ->
|
||||
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||
EventAttendee(
|
||||
email = email,
|
||||
name = a.name,
|
||||
optional = a.type == AttendeeType.Optional,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -117,9 +157,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
||||
* while the form was open. The raw row times ride along because
|
||||
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
||||
* re-deriving with the same occurrence would mask an externally moved
|
||||
* event. Not covered (the form can't write them, and the dirty-checked
|
||||
* write can't clobber them): attendees, status, the user's own response,
|
||||
* reminder methods, and a recurring event's duration.
|
||||
* event. Guests are covered (the form writes editable attendees), so an
|
||||
* external attendee change now also trips the conflict check. Still not
|
||||
* covered: status, the user's own response, reminder methods, the
|
||||
* organizer/resource rows, and a recurring event's duration.
|
||||
*/
|
||||
data class EditSnapshot(
|
||||
val form: EventForm,
|
||||
@@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||
}
|
||||
|
||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -12,20 +14,28 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||
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
|
||||
@@ -34,14 +44,24 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Holds the active top-level view (spec M1) and swaps between the calendar
|
||||
* Holds the top-level view back stack (spec M1) and swaps between the calendar
|
||||
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||
* pill in their top bars writes back here via [onSelectView].
|
||||
*
|
||||
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
|
||||
* A lateral switch (pill / drawer) builds a visit history so back retraces it
|
||||
* (see [selectView]); a widget launch resets the stack to its own view; a date
|
||||
* tap drills the day view on top. Pressing back pops one level, and the
|
||||
* base-level [BackHandler] hands off to the system to exit once only the home
|
||||
* view remains. So back from a widget-opened screen returns to that widget's
|
||||
* view, then home; and back through pill switches walks the views in reverse.
|
||||
*
|
||||
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||
* reminder notification routed through MainActivity): it opens the detail
|
||||
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
||||
* so a later recomposition can't re-open it.
|
||||
* so a later recomposition can't re-open it. (A widget event tap instead arrives
|
||||
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
|
||||
* widget's view.)
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarHost(
|
||||
@@ -52,15 +72,24 @@ fun CalendarHost(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
||||
// Wait for the persisted default view before seeding the stack, so the app
|
||||
// opens straight on the user's choice instead of flashing a placeholder and
|
||||
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
||||
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
||||
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
val view = viewStack.last()
|
||||
val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
|
||||
|
||||
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||
pendingDayIso = date.toString()
|
||||
view = CalendarView.Day
|
||||
viewStack = viewStack.drillToDay()
|
||||
}
|
||||
|
||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||
@@ -97,6 +126,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 +172,50 @@ fun CalendarHost(
|
||||
}
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||
// 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), open an
|
||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||
// view so backing out returns there (then home), not to the default.
|
||||
LaunchedEffect(widgetNavRequest) {
|
||||
when (val req = widgetNavRequest) {
|
||||
is WidgetNavRequest.OpenDate -> {
|
||||
// Drill the day view in over the widget's view: drop any overlay
|
||||
// that would cover it, so the open doesn't land under Settings/form.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = req.dateIso
|
||||
view = CalendarView.Day
|
||||
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenEvent -> {
|
||||
// Root the stack in the widget's view, then open the occurrence
|
||||
// detail over it (same key shape as a tapped event / reminder).
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
viewStack = viewBaseStack(defaultView, req.source)
|
||||
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||
heldKey = key
|
||||
detailKey = key
|
||||
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
|
||||
@@ -161,36 +230,73 @@ fun CalendarHost(
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||
// owns its own BackHandler and takes precedence). Disabled at the home view,
|
||||
// so back there falls through to the system and exits the app.
|
||||
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
|
||||
editKey != null || showSettings || showCalendars || importUri != null ||
|
||||
importForm != null
|
||||
BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
|
||||
viewStack = viewStack.dropLast(1)
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (view) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||
// navigation, so it fades through rather than sliding — paging *within* a
|
||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||
val viewSwitch = calendarFadeThrough()
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
label = "view-switch",
|
||||
) { currentView ->
|
||||
when (currentView) {
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
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,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -293,3 +399,9 @@ fun CalendarHost(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists the view back stack across config change / process death by ordinal. */
|
||||
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Supplies [CalendarHost] with the user's default startup view (M1). Held as a
|
||||
* nullable [StateFlow] so the host can wait for DataStore's first emission before
|
||||
* seeding its view back stack — rendering nothing for that first frame rather
|
||||
* than flashing the old hard-coded Week view and then correcting it.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarHostViewModel @Inject constructor(
|
||||
prefs: SettingsPrefs,
|
||||
) : ViewModel() {
|
||||
|
||||
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -20,6 +23,7 @@ import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RootScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -51,32 +55,40 @@ fun RootScreen(
|
||||
onDispose { lifecycle.removeObserver(obs) }
|
||||
}
|
||||
|
||||
if (hasPermission) {
|
||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||
// Null until DataStore's first emission — render nothing for that
|
||||
// frame instead of flashing the wrong screen.
|
||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||
when (onboardingDone) {
|
||||
true -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||
// onboarding eases into the next screen instead of snapping. A fade carries no
|
||||
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
||||
if (granted) {
|
||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||
// Null until DataStore's first emission — render nothing for that
|
||||
// frame instead of flashing the wrong screen.
|
||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||
when (done) {
|
||||
true -> CalendarHost(
|
||||
modifier = modifier,
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||
widgetNavRequest = widgetNavRequest,
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
modifier = modifier,
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
null -> {}
|
||||
}
|
||||
} else {
|
||||
PermissionScreen(
|
||||
onGranted = { hasPermission = true },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
package de.jeanlucmakiola.calendula.ui
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
|
||||
/**
|
||||
* A navigation a home-screen widget asked the app to perform when launched.
|
||||
* Parsed from the launch intent in MainActivity and consumed once by
|
||||
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
|
||||
* they are not modelled here).
|
||||
* [CalendarHost]. Every request carries the [source] view of the widget it came
|
||||
* from (the agenda widget → [CalendarView.Agenda], the month widget →
|
||||
* [CalendarView.Month]) so the in-app back stack roots itself in that view:
|
||||
* backing out of the opened date/event returns to the widget's own view, not the
|
||||
* default home. (Reminder notifications are not widgets — they keep the separate
|
||||
* detail-key channel and leave the base view untouched.)
|
||||
*/
|
||||
sealed interface WidgetNavRequest {
|
||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
|
||||
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
||||
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
|
||||
|
||||
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
||||
data class OpenEvent(
|
||||
val eventId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val source: CalendarView,
|
||||
) : WidgetNavRequest
|
||||
|
||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||
|
||||
@@ -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
|
||||
@@ -46,6 +47,7 @@ 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.calendarAnimateItem
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
@@ -75,6 +77,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 +122,7 @@ fun AgendaScreen(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
@@ -187,6 +191,7 @@ private fun AgendaList(
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
@@ -218,11 +223,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
@@ -274,6 +281,7 @@ private fun AgendaTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -292,6 +300,12 @@ private fun AgendaTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
@@ -73,6 +72,7 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
@@ -295,10 +295,9 @@ private fun CalendarEditor(
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
|
||||
BackHandler(onBack = onClose)
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
topBar = {
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.provider.Settings
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Whether the user has asked the system to remove animations (Settings →
|
||||
* Accessibility → "Remove animations", which sets the global animator duration
|
||||
* scale to 0). Compose animations do *not* honour this platform flag on their
|
||||
* own, so the shared motion helpers in this file check it and fall back to a
|
||||
* quick cross-fade — opacity only, no spatial movement — to respect the
|
||||
* vestibular intent of the setting while keeping state changes legible.
|
||||
*
|
||||
* Read once at composition; the scale changes rarely and only takes full effect
|
||||
* after a process restart anyway.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberReduceMotion(): Boolean {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
return remember(resolver) {
|
||||
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||
@@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
|
||||
/**
|
||||
* Horizontal slide for navigating between adjacent months/weeks.
|
||||
* The fast effects spec from the active motion scheme, for opacity (fade)
|
||||
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||
* for use in non-composable transition lambdas and as the reduced-motion fallback.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
|
||||
/**
|
||||
* Horizontal slide for navigating between adjacent months/weeks/days.
|
||||
*
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||
* (e.g. "today"); a jump reuses the forward direction.
|
||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||
* (e.g. "today"); a jump reuses the forward direction.
|
||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||
* @param fadeSpec effects spec for the reduced-motion fade, typically
|
||||
* [rememberCalendarFadeSpec].
|
||||
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
|
||||
*/
|
||||
fun calendarSlideTransition(
|
||||
slideDir: Int,
|
||||
spec: FiniteAnimationSpec<IntOffset>,
|
||||
fadeSpec: FiniteAnimationSpec<Float>,
|
||||
reduceMotion: Boolean,
|
||||
): ContentTransform {
|
||||
if (reduceMotion) {
|
||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||
}
|
||||
val dir = if (slideDir == 0) 1 else slideDir
|
||||
return slideInHorizontally(spec) { w -> dir * w }
|
||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
|
||||
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
|
||||
* opacity, so it doubles as its own reduced-motion form.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarFadeThrough(): ContentTransform {
|
||||
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
return fadeIn(fade).togetherWith(fadeOut(fade))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter transition for a vertically-revealed section (expandable rows, inline
|
||||
* fields): height grows from the top while fading in. Under reduced motion the
|
||||
* height growth is dropped, leaving a quick fade.
|
||||
*
|
||||
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
|
||||
* originally inlined in the event edit form, so every expandable surface in the
|
||||
* app reveals the same way.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||
}
|
||||
}
|
||||
|
||||
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
||||
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
|
||||
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
|
||||
* motion keeps the fade only.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
|
||||
* app's motion scheme instead of Compose's default spring. Returns a bare
|
||||
* [Modifier] under reduced motion so rows snap into place. Requires the list to
|
||||
* supply stable item keys.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
||||
if (reduceMotion) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.animateItem(
|
||||
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Android predictive-back transform for a full-screen overlay: as the
|
||||
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
||||
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
||||
* gesture invokes [onBack]; cancelling springs it back.
|
||||
*
|
||||
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
||||
* once and apply the returned [Modifier] to that screen's root so the preview
|
||||
* respects the same back semantics. Under reduced motion the visual preview is
|
||||
* skipped (the back still works); on API < 34 the system delivers no progress,
|
||||
* so it degrades to a plain back.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.predictiveBack(
|
||||
onBack: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
reduceMotion: Boolean = rememberReduceMotion(),
|
||||
): Modifier {
|
||||
val progress = remember { Animatable(0f) }
|
||||
var fromLeftEdge by remember { mutableStateOf(true) }
|
||||
|
||||
PredictiveBackHandler(enabled = enabled) { events ->
|
||||
try {
|
||||
events.collect { event ->
|
||||
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
||||
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
||||
}
|
||||
onBack()
|
||||
progress.snapTo(0f)
|
||||
} catch (_: CancellationException) {
|
||||
progress.animateTo(0f)
|
||||
}
|
||||
}
|
||||
|
||||
if (reduceMotion) return this
|
||||
return this.graphicsLayer {
|
||||
val p = progress.value
|
||||
val scale = 1f - 0.1f * p
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
|
||||
shape = RoundedCornerShape(32.dp.toPx() * p)
|
||||
clip = p > 0f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,3 +49,31 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||
return available[(i + 1) % available.size]
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level view back stack (bottom → top): the [default] home view always
|
||||
* sits at the bottom. Pressing back pops one level until only the home view
|
||||
* remains, then the system exits the app. Three operations move it:
|
||||
*
|
||||
* - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit
|
||||
* history so back retraces it: switching to a not-yet-visited view pushes it on
|
||||
* top; switching to one already in the stack pops back to it (collapsing the
|
||||
* loop, so the depth stays bounded by the number of distinct views). Selecting
|
||||
* the home view collapses to just `[default]`.
|
||||
* - [viewBaseStack] — the stack a widget launch resets to: just `[default]` when
|
||||
* the widget's view is the home view, else `[default, source]`. A widget entry
|
||||
* starts a fresh context rather than extending in-app history.
|
||||
* - [drillToDay] — pushes the day view on top (a date tap from the month grid or
|
||||
* a widget), unless the day view is already current.
|
||||
*/
|
||||
fun List<CalendarView>.selectView(target: CalendarView): List<CalendarView> {
|
||||
val existing = indexOf(target)
|
||||
return if (existing >= 0) take(existing + 1) else this + target
|
||||
}
|
||||
|
||||
fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarView> =
|
||||
if (source == default) listOf(default) else listOf(default, source)
|
||||
|
||||
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
||||
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -73,11 +72,11 @@ fun CollapsingScaffold(
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
BackHandler(onBack = onBack)
|
||||
val scrollBehavior =
|
||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.predictiveBack(onBack = onBack)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Instant> {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,11 @@ fun ReminderDefaultPicker(
|
||||
},
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(visible = customExpanded) {
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
CustomReminderEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
|
||||
@@ -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,8 +72,11 @@ 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.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
@@ -105,6 +109,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 +193,7 @@ fun DayScreen(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
@@ -233,6 +239,8 @@ private fun DayContent(
|
||||
val threshold = with(density) { 24.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
||||
// survives day-to-day swipes. We only centre on noon once, on first entry
|
||||
@@ -283,7 +291,7 @@ private fun DayContent(
|
||||
DayUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
@@ -340,6 +348,7 @@ private fun DayTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -358,6 +367,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 +511,7 @@ private fun Timeline(
|
||||
blocks = state.timed,
|
||||
dark = dark,
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
modifier = Modifier
|
||||
@@ -512,6 +528,7 @@ private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
dark: Boolean,
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -556,6 +573,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
@@ -92,6 +91,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
@@ -132,8 +132,6 @@ fun EventDetailScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -214,6 +212,7 @@ fun EventDetailScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = onBack),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -590,10 +589,22 @@ private fun AttendeeRow(attendee: Attendee) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val hasName = attendee.name.isNotBlank()
|
||||
Text(
|
||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||
text = if (hasName) attendee.name else attendee.email.orEmpty(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
// Email as supporting text — only when the name is the headline, so we
|
||||
// don't repeat it on rows that already fall back to the email above.
|
||||
if (hasName) {
|
||||
attendee.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||
Text(
|
||||
text = email,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
attendeeRoleLabel(attendee)?.let { roleRes ->
|
||||
Text(
|
||||
text = stringResource(roleRes),
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
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 androidx.activity.compose.BackHandler
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Patterns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
@@ -30,7 +31,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
@@ -38,10 +38,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,12 +53,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.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -82,13 +85,17 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -97,6 +104,7 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
@@ -107,6 +115,9 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
@@ -115,6 +126,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
|
||||
@@ -186,8 +199,6 @@ fun EventEditScreen(
|
||||
viewModel.reset()
|
||||
onClose()
|
||||
}
|
||||
BackHandler(onBack = close)
|
||||
|
||||
// The event vanished between the detail screen and the edit tap — fall
|
||||
// back to the detail screen, which shows its own failure state.
|
||||
LaunchedEffect(loadFailed) {
|
||||
@@ -237,6 +248,7 @@ fun EventEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = close),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -394,7 +406,6 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
|
||||
* spring open with the expressive spatial spec instead of popping in.
|
||||
* (Initially-visible sections render without animating.)
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun OptionalFormSection(
|
||||
visible: Boolean,
|
||||
@@ -402,10 +413,8 @@ private fun OptionalFormSection(
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
|
||||
fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()),
|
||||
exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
|
||||
fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
||||
}
|
||||
@@ -429,6 +438,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 +600,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 +655,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 +835,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 +945,7 @@ private fun EventEditContent(
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (showRecurrencePicker) {
|
||||
RecurrencePickerDialog(
|
||||
current = form.rrule,
|
||||
@@ -1262,45 +1368,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<String, String>? =
|
||||
context.contentResolver.query(
|
||||
uri,
|
||||
arrayOf(
|
||||
ContactsContract.CommonDataKinds.Email.ADDRESS,
|
||||
ContactsContract.Contacts.DISPLAY_NAME,
|
||||
),
|
||||
null, null, null,
|
||||
)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return@use null
|
||||
val email = cursor.getString(0).orEmpty().trim()
|
||||
val name = cursor.getString(1).orEmpty().trim()
|
||||
email.takeIf { it.isNotEmpty() }?.let { it to name }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +1644,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 +1655,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
EventFormField.Visibility -> Icons.Default.Lock
|
||||
EventFormField.Color -> Icons.Default.Palette
|
||||
EventFormField.Attendees -> Icons.Default.People
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
@@ -353,6 +354,33 @@ class EventEditViewModel @Inject constructor(
|
||||
update { it.copy(reminders = it.reminders - minutes) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
|
||||
* address just refreshes it (no duplicate row). A blank email is ignored —
|
||||
* the email is what we write and dedup on.
|
||||
*/
|
||||
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
|
||||
val trimmed = email.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
update { form ->
|
||||
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
|
||||
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
|
||||
form.copy(attendees = rest + attendee)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAttendee(attendee: EventAttendee) =
|
||||
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
|
||||
|
||||
/** Flip a guest between required and optional (the per-row role toggle). */
|
||||
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
|
||||
form.copy(
|
||||
attendees = form.attendees.map { a ->
|
||||
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Moving the start drags the end along, preserving the duration. */
|
||||
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.imports
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -39,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
|
||||
/**
|
||||
@@ -57,7 +57,6 @@ fun ImportScreen(
|
||||
) {
|
||||
LaunchedEffect(uri) { viewModel.load(uri) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
BackHandler(onBack = onClose)
|
||||
|
||||
// A single event isn't shown here — it opens the create form for review.
|
||||
LaunchedEffect(state) {
|
||||
@@ -65,7 +64,9 @@ fun ImportScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.import_title)) },
|
||||
|
||||
@@ -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
|
||||
@@ -65,6 +66,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
@@ -86,6 +89,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 +165,7 @@ fun MonthScreen(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
@@ -214,6 +219,8 @@ private fun MonthContent(
|
||||
val threshold = with(density) { 6.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
@@ -240,7 +247,7 @@ private fun MonthContent(
|
||||
MonthUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "month-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
@@ -261,6 +268,7 @@ private fun MonthTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -279,6 +287,12 @@ private fun MonthTopBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onOpenSearch) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = stringResource(R.string.search_action),
|
||||
)
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
onCycle = onCycleView,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
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.calendarAnimateItem
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
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()
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier.predictiveBack(onBack = onBack),
|
||||
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<EventInstance>,
|
||||
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),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
GroupedRow(
|
||||
modifier = modifier,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<EventInstance>) : 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<String> = _query.asStateFlow()
|
||||
|
||||
val state: StateFlow<SearchUiState> = _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<EventInstance>): List<EventInstance> {
|
||||
val now = Clock.System.now()
|
||||
val (upcoming, past) = events.partition { it.end >= now }
|
||||
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
|
||||
}
|
||||
}
|
||||
@@ -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,11 +84,16 @@ 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
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
@@ -398,6 +408,7 @@ private fun AppearanceScreen(
|
||||
) {
|
||||
var showTheme by remember { mutableStateOf(false) }
|
||||
var showWeekStart by remember { mutableStateOf(false) }
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
@@ -409,6 +420,12 @@ private fun AppearanceScreen(
|
||||
position = Position.Top,
|
||||
onClick = { showTheme = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
summary = stringResource(state.defaultView.labelRes),
|
||||
position = Position.Middle,
|
||||
onClick = { showDefaultView = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dynamic_color),
|
||||
summary = if (state.dynamicColorAvailable) {
|
||||
@@ -458,6 +475,16 @@ private fun AppearanceScreen(
|
||||
onDismiss = { showWeekStart = false },
|
||||
)
|
||||
}
|
||||
if (showDefaultView) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
options = IMPLEMENTED_VIEWS,
|
||||
selected = state.defaultView,
|
||||
label = { stringResource(it.labelRes) },
|
||||
onSelect = viewModel::setDefaultView,
|
||||
onDismiss = { showDefaultView = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -513,9 +540,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 +603,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<OverrideTarget?>(null) }
|
||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||
|
||||
@@ -618,7 +676,11 @@ private fun NotificationsScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
Column {
|
||||
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||
GroupedRow(
|
||||
@@ -666,8 +728,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 +873,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 +965,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
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
@@ -17,6 +18,8 @@ data class SettingsUiState(
|
||||
val dynamicColor: Boolean = true,
|
||||
val dynamicColorAvailable: Boolean = true,
|
||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||
@@ -30,6 +33,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
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -59,8 +60,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,
|
||||
@@ -69,12 +73,15 @@ class SettingsViewModel @Inject constructor(
|
||||
) { overrides, allDayOverrides, calendars ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||
},
|
||||
) { base, defaults, overrides ->
|
||||
prefs.defaultView,
|
||||
) { base, defaults, overrides, defaultView ->
|
||||
base.copy(
|
||||
defaultView = defaultView,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
allDayReminderTimeMinutes = defaults.allDayReminderTime,
|
||||
snoozeMinutes = defaults.snoozeMinutes,
|
||||
perCalendarReminderOverride = overrides.timed,
|
||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||
writableCalendars = overrides.calendars,
|
||||
@@ -90,6 +97,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val defaultReminder: Int?,
|
||||
val allDayReminder: Int?,
|
||||
val allDayReminderTime: Int,
|
||||
val snoozeMinutes: Int,
|
||||
)
|
||||
|
||||
private data class ReminderOverrides(
|
||||
@@ -110,6 +118,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||
}
|
||||
|
||||
fun setDefaultView(view: CalendarView) {
|
||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||
}
|
||||
|
||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||
}
|
||||
@@ -130,6 +142,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) }
|
||||
}
|
||||
|
||||
@@ -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,8 +77,11 @@ 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.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
@@ -113,6 +117,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 +198,7 @@ fun WeekScreen(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
@@ -243,6 +249,8 @@ private fun WeekContent(
|
||||
val threshold = with(density) { 24.dp.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
||||
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
|
||||
@@ -296,7 +304,7 @@ private fun WeekContent(
|
||||
WeekUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
@@ -351,6 +359,7 @@ private fun WeekTopBar(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
@@ -369,6 +378,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 +625,7 @@ private fun Timeline(
|
||||
blocks = state.timedByDay[day].orEmpty(),
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = state.today,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
modifier = Modifier
|
||||
@@ -628,6 +644,7 @@ private fun DayColumnCard(
|
||||
blocks: List<TimedBlock>,
|
||||
dark: Boolean,
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -671,6 +688,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
@@ -185,7 +186,11 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
||||
.clickable(actionStartActivity(MainActivity.openDateIntent(context, date))),
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -199,11 +204,12 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.eventDetailIntent(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = CalendarView.Agenda,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.glance.unit.ColorProvider
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
@@ -209,7 +210,9 @@ private fun MonthHeader(label: String) {
|
||||
resId = R.drawable.ic_widget_today,
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
||||
),
|
||||
),
|
||||
)
|
||||
HeaderIcon(
|
||||
@@ -300,8 +303,16 @@ private fun WeekRow(
|
||||
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Box(
|
||||
modifier = GlanceModifier.width(colW).height(DAY_NUMBER_HEIGHT),
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(DAY_NUMBER_HEIGHT)
|
||||
// Tap a day number to open that day, rooted in the month view so back
|
||||
// returns to the month grid.
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
@@ -353,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
@Composable
|
||||
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||
val context = LocalContext.current
|
||||
Box(modifier = GlanceModifier.width(width).height(LANE_HEIGHT).padding(horizontal = 1.dp)) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.width(width)
|
||||
.height(LANE_HEIGHT)
|
||||
.padding(horizontal = 1.dp)
|
||||
// Tap an event bar to open its detail, rooted in the month view.
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openEventIntent(
|
||||
context = context,
|
||||
eventId = event.eventId,
|
||||
beginMillis = event.start.toEpochMilliseconds(),
|
||||
endMillis = event.end.toEpochMilliseconds(),
|
||||
source = CalendarView.Month,
|
||||
),
|
||||
),
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxSize()
|
||||
|
||||
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
12
app/src/main/res/drawable/ic_notification_dismiss.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Monochrome notification-action mark: Material "close" glyph. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||
</vector>
|
||||
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
12
app/src/main/res/drawable/ic_notification_snooze.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Monochrome notification-action mark: Material "snooze" glyph. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7zM9,11h3.63L9,15.2L9,17h6v-2h-3.63L15,10.8L15,9L9,9z" />
|
||||
</vector>
|
||||
18
app/src/main/res/drawable/ic_qs_new_event.xml
Normal file
18
app/src/main/res/drawable/ic_qs_new_event.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Quick Settings tile icon ("New event"): a flat calendar+plus glyph. QS tile
|
||||
icons are tinted by the system from their alpha, so this is a bare white
|
||||
glyph on a transparent background (no brand circle, unlike the launcher
|
||||
shortcut icon). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFFFF">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M11.5,10.5h1v2h2v1h-2v2h-1v-2h-2v-1h2z" />
|
||||
</vector>
|
||||
@@ -77,6 +77,16 @@
|
||||
<string name="event_edit_add">Hinzufügen</string>
|
||||
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
|
||||
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
|
||||
<string name="event_edit_attendees">Gäste</string>
|
||||
<string name="event_edit_add_guest">Gast hinzufügen</string>
|
||||
<string name="event_edit_add_guest_hint">Gast per E-Mail hinzufügen…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Aus Kontakten hinzufügen</string>
|
||||
<string name="event_edit_location_from_contacts">Adresse aus Kontakten wählen</string>
|
||||
<string name="event_edit_remove_guest">Gast entfernen</string>
|
||||
<string name="event_edit_attendee_required">Erforderlich</string>
|
||||
<string name="event_edit_attendee_optional">Optional</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula versendet keine Einladungen. Dein Kalenderkonto sendet Gästen bei der Synchronisierung unter Umständen eine E-Mail.</string>
|
||||
<string name="event_edit_attendees_note_local">Auf diesem Gerät gespeichert. Niemand wird benachrichtigt.</string>
|
||||
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
|
||||
<string name="reminder_unit_minutes">Minuten</string>
|
||||
<string name="reminder_unit_hours">Stunden</string>
|
||||
@@ -177,6 +187,15 @@
|
||||
<item quantity="one">%d Woche vorher</item>
|
||||
<item quantity="other">%d Wochen vorher</item>
|
||||
</plurals>
|
||||
<!-- Reine Zeitdauern (ohne „vorher"), z. B. die Schlummerdauer. -->
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d Minute</item>
|
||||
<item quantity="other">%d Minuten</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d Stunde</item>
|
||||
<item quantity="other">%d Stunden</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Geteilte Event-Strings -->
|
||||
<string name="event_untitled">(Ohne Titel)</string>
|
||||
@@ -194,6 +213,8 @@
|
||||
<string name="reminder_benefit_reversible_body">Der Schalter liegt in den Einstellungen unter Benachrichtigungen.</string>
|
||||
<string name="reminder_onboarding_enable_button">Erinnerungen einschalten</string>
|
||||
<string name="reminder_onboarding_skip_button">Später</string>
|
||||
<string name="reminder_action_snooze">Schlummern</string>
|
||||
<string name="reminder_action_dismiss">Verwerfen</string>
|
||||
|
||||
<!-- View-Switcher (M1) -->
|
||||
<string name="view_month">Monat</string>
|
||||
@@ -212,6 +233,14 @@
|
||||
<string name="agenda_empty_title">Nichts geplant</string>
|
||||
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
|
||||
|
||||
<!-- Terminsuche -->
|
||||
<string name="search_action">Suchen</string>
|
||||
<string name="search_hint">Termine suchen</string>
|
||||
<string name="search_back">Zurück</string>
|
||||
<string name="search_clear">Löschen</string>
|
||||
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
||||
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
||||
|
||||
<!-- Startbildschirm-Widgets -->
|
||||
<string name="widget_agenda_title">Anstehend</string>
|
||||
<string name="widget_agenda_label">Calendula Agenda</string>
|
||||
@@ -227,6 +256,11 @@
|
||||
<string name="shortcut_new_event_short">Neuer Termin</string>
|
||||
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
||||
|
||||
<!-- Schnelleinstellungen-Kachel -->
|
||||
<string name="qs_tile_new_event_label">Neuer Termin</string>
|
||||
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
|
||||
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
|
||||
|
||||
<!-- Kalender-Filter (M3) -->
|
||||
<string name="filter_title">Kalender</string>
|
||||
|
||||
@@ -265,6 +299,7 @@
|
||||
<string name="settings_reliable_delivery">Zuverlässige Zustellung</string>
|
||||
<string name="settings_reliable_delivery_hint">Android verzögert Erinnerungen womöglich, um Akku zu sparen. Nimm Calendula aus, damit sie pünktlich ankommen.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Von der Akku-Optimierung ausgenommen — Erinnerungen kommen pünktlich.</string>
|
||||
<string name="settings_snooze_duration">Schlummerdauer</string>
|
||||
<string name="settings_section_calendars">Kalender</string>
|
||||
<string name="settings_manage_calendars">Kalender verwalten</string>
|
||||
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
||||
|
||||
@@ -78,6 +78,16 @@
|
||||
<string name="event_edit_add">Add</string>
|
||||
<string name="event_edit_add_reminder">Add reminder</string>
|
||||
<string name="event_edit_remove_reminder">Remove reminder</string>
|
||||
<string name="event_edit_attendees">Guests</string>
|
||||
<string name="event_edit_add_guest">Add guest</string>
|
||||
<string name="event_edit_add_guest_hint">Add a guest by email…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Add from contacts</string>
|
||||
<string name="event_edit_location_from_contacts">Pick address from contacts</string>
|
||||
<string name="event_edit_remove_guest">Remove guest</string>
|
||||
<string name="event_edit_attendee_required">Required</string>
|
||||
<string name="event_edit_attendee_optional">Optional</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula doesn\'t send invitations. Your calendar account may email guests when it syncs.</string>
|
||||
<string name="event_edit_attendees_note_local">Stored on this device. No one is notified.</string>
|
||||
<string name="event_edit_reminder_custom">Custom</string>
|
||||
<string name="reminder_unit_minutes">minutes</string>
|
||||
<string name="reminder_unit_hours">hours</string>
|
||||
@@ -178,6 +188,15 @@
|
||||
<item quantity="one">%d week before</item>
|
||||
<item quantity="other">%d weeks before</item>
|
||||
</plurals>
|
||||
<!-- Plain durations (no "before"), e.g. the snooze delay. -->
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d minute</item>
|
||||
<item quantity="other">%d minutes</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d hour</item>
|
||||
<item quantity="other">%d hours</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
@@ -195,6 +214,8 @@
|
||||
<string name="reminder_benefit_reversible_body">The switch lives in Settings, under Notifications.</string>
|
||||
<string name="reminder_onboarding_enable_button">Turn on reminders</string>
|
||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||
<string name="reminder_action_snooze">Snooze</string>
|
||||
<string name="reminder_action_dismiss">Dismiss</string>
|
||||
|
||||
<!-- View switcher (M1) -->
|
||||
<string name="view_month">Month</string>
|
||||
@@ -213,6 +234,14 @@
|
||||
<string name="agenda_empty_title">Nothing scheduled</string>
|
||||
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
|
||||
|
||||
<!-- Event search -->
|
||||
<string name="search_action">Search</string>
|
||||
<string name="search_hint">Search events</string>
|
||||
<string name="search_back">Back</string>
|
||||
<string name="search_clear">Clear</string>
|
||||
<string name="search_idle_hint">Search your events by title, location or notes.</string>
|
||||
<string name="search_empty">No events match “%1$s”.</string>
|
||||
|
||||
<!-- Home-screen widgets -->
|
||||
<string name="widget_agenda_title">Upcoming</string>
|
||||
<string name="widget_agenda_label">Calendula agenda</string>
|
||||
@@ -235,6 +264,7 @@
|
||||
<string name="settings_theme_system">System</string>
|
||||
<string name="settings_theme_light">Light</string>
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_default_view">Default view</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
<string name="settings_week_start">Week starts on</string>
|
||||
@@ -262,6 +292,7 @@
|
||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
|
||||
<string name="settings_snooze_duration">Snooze duration</string>
|
||||
<string name="settings_section_calendars">Calendars</string>
|
||||
<string name="settings_manage_calendars">Manage calendars</string>
|
||||
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
||||
@@ -269,7 +300,7 @@
|
||||
<string name="settings_language">App language</string>
|
||||
<string name="settings_language_auto">System default</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
|
||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
@@ -340,6 +371,11 @@
|
||||
<string name="shortcut_new_event_short">New event</string>
|
||||
<string name="shortcut_new_event_long">Create a new event</string>
|
||||
|
||||
<!-- Quick Settings tile -->
|
||||
<string name="qs_tile_new_event_label">New event</string>
|
||||
<string name="settings_qs_tile">Add Quick Settings tile</string>
|
||||
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</string>
|
||||
|
||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||
|
||||
@@ -355,6 +391,6 @@
|
||||
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
||||
<string name="crash_report_body_template">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</string>
|
||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new</string>
|
||||
<string name="report_issue_choose_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues/new/choose</string>
|
||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||
</resources>
|
||||
|
||||
@@ -16,6 +16,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
|
||||
var calendarsResult: List<CalendarSource> = emptyList()
|
||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||
var searchResult: (String) -> List<EventInstance> = { _ -> emptyList() }
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||
@@ -51,6 +52,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun calendars(): List<CalendarSource> = calendarsResult
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
instancesResult(beginMillis, endMillis)
|
||||
override fun searchEvents(query: String): List<EventInstance> = searchResult(query)
|
||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
eventColorPaletteResult(calendarId)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The pure back-stack rules behind [CalendarHost]'s top-level navigation:
|
||||
* the home view is always the bottom, a lateral move keeps the stack one deep,
|
||||
* and a date tap drills the day view on top. Verifies the back sequences the
|
||||
* widget-UX fix promises (e.g. agenda widget → day → back → agenda → back → home).
|
||||
*/
|
||||
class ViewBackStackTest {
|
||||
|
||||
@Test
|
||||
fun `a widget whose view is the home view resets to just the home`() {
|
||||
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Week))
|
||||
.containsExactly(CalendarView.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a widget launch resets to its view over the home`() {
|
||||
assertThat(viewBaseStack(CalendarView.Week, CalendarView.Month))
|
||||
.containsExactly(CalendarView.Week, CalendarView.Month)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pill switches build a visit history that back retraces`() {
|
||||
// Agenda → Day → Week → Month, each a new view, stacks the full history.
|
||||
val stack = listOf(CalendarView.Agenda)
|
||||
.selectView(CalendarView.Day)
|
||||
.selectView(CalendarView.Week)
|
||||
.selectView(CalendarView.Month)
|
||||
assertThat(stack).containsExactly(
|
||||
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
|
||||
).inOrder()
|
||||
// Back walks the views in reverse, not straight to the home view.
|
||||
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting an already-visited view collapses the loop back to it`() {
|
||||
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
|
||||
.selectView(CalendarView.Agenda)
|
||||
assertThat(stack).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting the home view collapses to just the home`() {
|
||||
val stack = listOf(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
|
||||
.selectView(CalendarView.Week)
|
||||
assertThat(stack).containsExactly(CalendarView.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drilling pushes the day view on top`() {
|
||||
assertThat(listOf(CalendarView.Week, CalendarView.Month).drillToDay())
|
||||
.containsExactly(CalendarView.Week, CalendarView.Month, CalendarView.Day)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drilling is a no-op when the day view is already current`() {
|
||||
val stack = listOf(CalendarView.Week, CalendarView.Day)
|
||||
assertThat(stack.drillToDay()).isEqualTo(stack)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agenda widget date tap backs out agenda then home`() {
|
||||
// openDate(source = Agenda): root in Agenda over the Week home, then drill.
|
||||
val stack = viewBaseStack(CalendarView.Week, CalendarView.Agenda).drillToDay()
|
||||
assertThat(stack)
|
||||
.containsExactly(CalendarView.Week, CalendarView.Agenda, CalendarView.Day)
|
||||
.inOrder()
|
||||
// Pressing back pops one level at a time down to the home view.
|
||||
assertThat(stack.dropLast(1)).containsExactly(CalendarView.Week, CalendarView.Agenda).inOrder()
|
||||
assertThat(stack.dropLast(2)).containsExactly(CalendarView.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `month widget date tap backs out month then home`() {
|
||||
val stack = viewBaseStack(CalendarView.Week, CalendarView.Month).drillToDay()
|
||||
assertThat(stack.last()).isEqualTo(CalendarView.Day)
|
||||
assertThat(stack.dropLast(1).last()).isEqualTo(CalendarView.Month)
|
||||
assertThat(stack.first()).isEqualTo(CalendarView.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a widget whose view is the home view does not duplicate the home`() {
|
||||
// Default = Agenda, agenda widget event tap → just the home, no extra layer.
|
||||
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
|
||||
.containsExactly(CalendarView.Agenda)
|
||||
}
|
||||
}
|
||||
38
fastlane/metadata/android/en-US/changelogs/20800.txt
Normal file
38
fastlane/metadata/android/en-US/changelogs/20800.txt
Normal file
@@ -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.
|
||||
|
||||
33
fastlane/metadata/android/en-US/changelogs/20900.txt
Normal file
33
fastlane/metadata/android/en-US/changelogs/20900.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
### Added
|
||||
- Choose the view Calendula opens on. A new **Default view** setting (Settings →
|
||||
Appearance) lets you pick Month, Week, Day or Agenda as the view shown each
|
||||
time you start the app, instead of always opening on Week. Thanks to
|
||||
@devinside for the suggestion ([#1]).
|
||||
- The month widget is now interactive: tap any day to open it, or tap an event to
|
||||
open its details. Previously only the month's prev/next/today controls
|
||||
responded. Thanks to @devinside for spotting this ([#2]).
|
||||
- Predictive back. Swiping back from a screen — an event, the editor, search or
|
||||
settings — now follows your finger, shrinking the screen to preview what's
|
||||
behind so you can see where Back will take you before you let go; slide it back
|
||||
to cancel. The preview needs Android 14 or newer; on older versions Back works
|
||||
as before.
|
||||
- Respect for "Remove animations". If you've turned animations off in your
|
||||
device's Accessibility settings, Calendula now honours it everywhere: motion
|
||||
collapses to a quick fade and the back preview is skipped.
|
||||
|
||||
### Changed
|
||||
- Smoother, more consistent motion throughout. Expanding sections, list updates
|
||||
in search and agenda, and the onboarding screens now animate the same way
|
||||
across the app, instead of some places sliding or growing while others popped
|
||||
in. Switching between Month, Week, Day and Agenda now cross-fades rather than
|
||||
snapping, while paging within a view keeps its slide.
|
||||
|
||||
### Fixed
|
||||
- Home-screen widgets now back out the way you came in. A day or event opened
|
||||
from the Agenda widget keeps you in the agenda context, and from the Month
|
||||
widget in the month context — pressing Back returns there instead of dropping
|
||||
you on the week view. More broadly, the app now keeps a real view history:
|
||||
switching views and drilling into a day are retraced by Back one step at a
|
||||
time, down to your default view before the app exits. Thanks to @devinside for
|
||||
reporting ([#2]).
|
||||
|
||||
@@ -1,23 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reproducibility guard for the official F-Droid repo.
|
||||
# Reproducibility guard for the official F-Droid repo (de.jeanlucmakiola.calendula).
|
||||
#
|
||||
# F-Droid only republishes OUR signed binary if a from-source build reproduces
|
||||
# it byte-for-byte. AGP's VCS-info injection writes
|
||||
# META-INF/version-control-info.textproto with build-environment git metadata
|
||||
# (commit hash / path) — env-dependent, and the one thing that broke
|
||||
# reproducibility before we disabled it. It MUST stay off on the release build,
|
||||
# or new versions silently stop publishing to the official repo (fails safe, but
|
||||
# you'd be stuck on an old version without noticing). Fail loudly if it regresses.
|
||||
# F-Droid only republishes OUR signed binary if a from-source build reproduces it
|
||||
# byte-for-byte and the binary carries no extra signing blocks. If any invariant
|
||||
# below regresses, the official repo silently stalls on the last good version
|
||||
# (fails safe — but you'd be stuck on an old release without noticing). So fail
|
||||
# loudly here, on every PR.
|
||||
#
|
||||
# Each invariant was learned from a real fdroiddata CI rejection (see
|
||||
# docs/fdroid-official/ and the v2.7.3 -> v2.7.5 history):
|
||||
# 1. vcsInfo { include = false } — else AGP embeds env-dependent git
|
||||
# metadata (META-INF/version-control-info.textproto) -> not reproducible.
|
||||
# 2. no foojay toolchain resolver — F-Droid's offline source scanner
|
||||
# rejects org.gradle.toolchains.foojay-resolver (it can fetch a JDK over
|
||||
# the network at build time).
|
||||
# 3. dependenciesInfo { includeInApk = false } — else AGP embeds a "Dependency
|
||||
# metadata" block (id 0x504b4453) in the APK Signing Block, which F-Droid's
|
||||
# binary scanner rejects as an extra signing block.
|
||||
set -euo pipefail
|
||||
F="app/build.gradle.kts"
|
||||
|
||||
# Match `vcsInfo { ... include = false ... }` within the block, tolerant of
|
||||
# whitespace/newlines (-z: whole file as one record; [^}] stays inside the block).
|
||||
if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$F"; then
|
||||
echo "OK: release build disables AGP VCS-info — stays reproducible for F-Droid."
|
||||
APP="app/build.gradle.kts"
|
||||
SETTINGS="settings.gradle.kts"
|
||||
fail=0
|
||||
|
||||
# 1. AGP VCS-info must be disabled on the release build. -z reads the whole file
|
||||
# as one record so the match can span newlines; [^}] keeps it inside the block.
|
||||
if grep -Pzoq 'vcsInfo\s*\{[^}]*include\s*=\s*false' "$APP"; then
|
||||
echo "OK: vcsInfo { include = false } — no env-dependent VCS metadata embedded."
|
||||
else
|
||||
echo "ERROR: '$F' release build is missing 'vcsInfo { include = false }'." >&2
|
||||
echo "AGP would embed env-dependent git metadata (version-control-info.textproto)," >&2
|
||||
echo "breaking F-Droid reproducible builds. Restore it. See docs/fdroid-official/." >&2
|
||||
echo "ERROR: '$APP' release build is missing 'vcsInfo { include = false }'." >&2
|
||||
echo " AGP would embed version-control-info.textproto, breaking reproducibility." >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# 2. The foojay toolchain resolver must not be present in any Gradle script.
|
||||
gradle_files=("$SETTINGS" "$APP")
|
||||
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
|
||||
if grep -qi 'foojay' "${gradle_files[@]}"; then
|
||||
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
|
||||
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2
|
||||
echo " (it can fetch a JDK over the network). Remove the plugin." >&2
|
||||
fail=1
|
||||
else
|
||||
echo "OK: no foojay toolchain resolver — offline build scanner stays happy."
|
||||
fi
|
||||
|
||||
# 3. AGP dependency-metadata block must not be embedded in the APK.
|
||||
if grep -Pzoq 'dependenciesInfo\s*\{[^}]*includeInApk\s*=\s*false' "$APP"; then
|
||||
echo "OK: dependenciesInfo { includeInApk = false } — no extra APK signing block."
|
||||
else
|
||||
echo "ERROR: '$APP' is missing 'dependenciesInfo { includeInApk = false }'." >&2
|
||||
echo " AGP would embed a 'Dependency metadata' block (0x504b4453) in the APK" >&2
|
||||
echo " Signing Block, which F-Droid's binary scanner rejects." >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo >&2
|
||||
echo "Reproducible-release invariant(s) violated — official F-Droid publishing would" >&2
|
||||
echo "stall. Fix the above before merging. See docs/fdroid-official/." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "All reproducible-release invariants hold."
|
||||
|
||||
Reference in New Issue
Block a user