Compare commits
90 Commits
v2.8.0
...
405ff16233
| Author | SHA1 | Date | |
|---|---|---|---|
| 405ff16233 | |||
| 048f407ba1 | |||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e | |||
| 2ae4c818ba | |||
| fb2a40114a | |||
| e1f79df67a | |||
| e258a4a7f2 | |||
| 3f27a47d1e | |||
| feb87a1e73 | |||
| 28e464a616 | |||
| 93df6d6d62 | |||
| 49ddb9437b | |||
| e1cf00999d | |||
| 2d0d707a7d | |||
| de1fe31223 | |||
| 273cfce969 | |||
| b6a45b7264 | |||
| c7ef52e426 | |||
| b1151b6516 | |||
| 1664625bc5 | |||
| e8b87446f7 | |||
| dc478516cd | |||
| 8945b877b2 | |||
| 1424f0ffc0 | |||
| 548e581554 | |||
| a6ae4d7e1d | |||
| 614f4f2d75 | |||
| e6736b049a | |||
| 301904be6d | |||
| 3ca7200624 | |||
| c30a153552 | |||
| bb92d02000 | |||
| cd703e9d54 | |||
| f27b0e269d | |||
| 0f31981d26 | |||
| 6471c48528 | |||
| f8a2569831 | |||
| 6ba7f38887 | |||
| 366059b012 | |||
| 3cf1439850 | |||
| b2bfdc0f42 | |||
| 7c9830deda | |||
| 5e98059388 | |||
| 53c0482760 | |||
| f8efa4d3e4 | |||
| 93cc264fbf | |||
| 585cdac4ab | |||
| 9994e8c534 | |||
| 766c2ffcf8 | |||
| add88fbadf | |||
| 09a1aecb76 | |||
| c91608e48a | |||
| 47e8036000 | |||
| 07a8faf2df | |||
| 54b059e744 | |||
| d0c01ab8c4 | |||
| bf10fee19f | |||
| 2e88c40eff | |||
| 26e538a9ba | |||
| 814daa8151 | |||
| 450fc7f7af | |||
| be6d10d126 | |||
| a1e8c05c7b | |||
| cecd0c2234 | |||
| 43eb5e23e8 | |||
| 946addbbcd | |||
| 8a63aa86a7 | |||
| 0ddbbb11e8 | |||
| 575b1c7eb7 | |||
| dd61e0ed37 | |||
| 376ad5f394 | |||
| 8a9e6f0ee8 | |||
| 913b49e962 | |||
| 96f4d4dc84 | |||
| f4789c535e | |||
| 00220c5f0e | |||
| b7a5f16c84 | |||
| 2c38912834 | |||
| a9c67a5827 | |||
| a69b161178 | |||
| 024deadf78 | |||
| 599c3c03e7 | |||
| 5d48c90408 | |||
| c6d15d0118 | |||
| 79e083b4d4 | |||
| 119a8afe8e | |||
| b537e143f8 |
@@ -5,6 +5,7 @@ title: "Crash: "
|
||||
labels:
|
||||
- bug
|
||||
- crash
|
||||
- priority:high
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Feature request
|
||||
about: Suggest an idea or improvement
|
||||
title: ""
|
||||
labels:
|
||||
- enhancement
|
||||
- feat
|
||||
---
|
||||
|
||||
### What would you like Calendula to do?
|
||||
|
||||
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask how something works or get help using Calendula
|
||||
title: ""
|
||||
labels:
|
||||
- question
|
||||
---
|
||||
|
||||
### Your question
|
||||
|
||||
|
||||
### What you've tried
|
||||
<!-- so far, if anything -->
|
||||
|
||||
|
||||
### Context
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
@@ -230,7 +230,7 @@ pass on the existing controls; new toggles ride in with their own features.
|
||||
|
||||
**Tier 3 — platform reach (depends on Tier 2)**
|
||||
7. ~~Home-screen widget — built on the agenda data source from #6~~ *(done, v2.5.0 — agenda + month widgets)*
|
||||
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; optional quick-settings tile still open
|
||||
8. App shortcuts: ~~launcher long-press → New event~~ *(done, v2.5.0)*; ~~quick-settings tile~~ *(done, v2.8.0 — "New event" QS tile)*
|
||||
|
||||
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
||||
@@ -251,37 +251,38 @@ pass on the existing controls; new toggles ride in with their own features.
|
||||
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.
|
||||
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
|
||||
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
|
||||
the event model: attendees were already *read* (queried, mapped, shown on the
|
||||
detail screen since v0.6) and are now *writable* from the form. An attendees
|
||||
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
|
||||
no-permission contact picker, edits/removes rows, and sets role (required /
|
||||
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
|
||||
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
|
||||
caveat was **settled record-only** (Calendula never sends invites; the backend
|
||||
decides delivery) — full sketch + decision 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)
|
||||
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
|
||||
- Locations & People — the no-permission contact pickers already shipped (location v2.8.0, attendee email v2.8.0); what remains gated is OSM autocomplete (needs INTERNET)
|
||||
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||
|
||||
**Bottom — deprioritised, not important**
|
||||
- Duplicate event (detail action → prefilled create form) — moved here
|
||||
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. Pulled in opportunistically, not sequenced.
|
||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
|
||||
Pulled in opportunistically, not sequenced.
|
||||
|
||||
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).
|
||||
export/import v2.7.0, #11 snooze/dismiss in release/v2.8.0; drag-drop rejected),
|
||||
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
|
||||
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers —
|
||||
full-text search, the "New event" Quick Settings tile, and the now-line all
|
||||
shipped there. **No tier work is currently committed as next;** the remaining
|
||||
candidates are unscheduled theme-group ideas (pinch-to-zoom, tablet/foldable,
|
||||
accessibility pass) plus the gated go/no-go items.
|
||||
|
||||
## Navigation & views
|
||||
|
||||
@@ -291,24 +292,22 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
|
||||
- Agenda view (fourth view: upcoming events grouped by day; also the
|
||||
natural data source for a future widget)
|
||||
- Jump to date — drawer date picker (un-cut from V1)
|
||||
- Current-time "now" line in day/week — standard in every calendar, cheap,
|
||||
currently absent. Daily-driver polish.
|
||||
- Week numbers in the **month** grid — week view already shows the badge
|
||||
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
|
||||
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
|
||||
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
|
||||
the view and shrinks the day cells; the badge stays week-view-only.
|
||||
- Pinch-to-zoom time scale in day/week
|
||||
- Tablet / foldable layouts *(was v3.0)*
|
||||
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver
|
||||
with real event history, finding an event is core completeness, not optional.
|
||||
- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event
|
||||
history, finding an event is core completeness, not optional.
|
||||
|
||||
## Event editing & creation
|
||||
|
||||
- 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
|
||||
management work: reuses the color-picker component and palette plumbing
|
||||
being built for local calendar management, and finishes the create/edit
|
||||
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
|
||||
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
|
||||
- ~~**Per-event color**~~ *(shipped v2.4.0)* — `EVENT_COLOR` / `EVENT_COLOR_KEY`
|
||||
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
|
||||
picker in the form, falling back to the calendar color when unset. Reused the
|
||||
color-picker component and palette plumbing from local calendar management and
|
||||
finished the create/edit theme.
|
||||
|
||||
## Calendars & accounts
|
||||
|
||||
@@ -337,7 +336,63 @@ in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
|
||||
`.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)*
|
||||
### Disable a calendar in-app *(captured 2026-06-25)*
|
||||
|
||||
A second, heavier visibility level **above** the existing per-view filter. Today
|
||||
the drawer's calendar filter (`hiddenCalendarIds` in `CalendarPrefs`) only hides
|
||||
a calendar's *events* from the month/week/day/agenda views — the calendar itself
|
||||
still clutters the drawer filter list, the event-form calendar picker, and the
|
||||
import target picker. "Disable" removes a calendar from the app's surfaces
|
||||
entirely; "hide" stays the lightweight, frequently-toggled control.
|
||||
|
||||
**Two-level model (both kept):**
|
||||
- **Hidden** (existing) — `hiddenCalendarIds`; a quick per-view checkbox in the
|
||||
drawer. Toggles events on/off in the views; the calendar stays listed
|
||||
everywhere. Operates only over the *enabled* calendars.
|
||||
- **Disabled** (new) — the calendar is gone from the app: not in the drawer
|
||||
filter list, not in the event-form picker, not in the import picker, and its
|
||||
events never appear (it's gated out of `instances()` like a hidden one). It
|
||||
remains visible **only** in Settings → Calendars, where the enable/disable
|
||||
toggle lives, so it can be brought back.
|
||||
|
||||
**Storage — app-side (DataStore), mirrors the hidden set.** Add
|
||||
`disabledCalendarIds: Set<Long>` + `setDisabledCalendarIds(...)` to
|
||||
`CalendarPrefs` (comma-separated string key, same shape as `hiddenCalendarIds`).
|
||||
**Does not touch** `CalendarContract.Calendars.VISIBLE` / `SYNC_EVENTS` — purely
|
||||
a Calendula-local preference, so other calendar apps are unaffected and the sync
|
||||
adapters stay out of it (privacy-clean, reversible).
|
||||
|
||||
**Where the disabled set is applied:**
|
||||
- `CalendarRepositoryImpl.instances()` + `searchEvents()` — exclude
|
||||
`calendarId ∈ (hidden ∪ disabled)` so a disabled calendar's events never show
|
||||
and aren't searchable. (`repository.calendars()` itself stays unfiltered/raw —
|
||||
the screens that need everything still get everything.)
|
||||
- `FilterViewModel.state` — drop disabled calendars from the drawer filter list
|
||||
(you can't hide/show what's disabled).
|
||||
- `EventEditViewModel.writableCalendars` — exclude disabled, so you can't create
|
||||
into a calendar you've removed from the app. Handle the last-used-calendar
|
||||
preselect falling on a now-disabled calendar (fall back to first enabled
|
||||
writable).
|
||||
- `ImportViewModel` — exclude disabled from the import target list.
|
||||
- `CalendarsScreen` / `CalendarsViewModel` — the **only** surface that lists
|
||||
disabled calendars; add `setDisabled(id, Boolean)` and a per-calendar toggle.
|
||||
|
||||
**UI — Settings → Calendars (no new tab).** Add an enable/disable control to each
|
||||
row on the existing `CalendarsScreen` (both the local and the synced/read-only
|
||||
groups — disabling is an app-side view choice, independent of write access).
|
||||
Disabled rows render visibly de-emphasised (dimmed) but keep the toggle so
|
||||
they're re-enableable. Follow the project dialog/list conventions (M3 grouped
|
||||
list, `option-card-modal-style-default` if a confirm/selection surface is needed).
|
||||
|
||||
**Decided behaviour:**
|
||||
- All calendars disabled → views show the existing empty state.
|
||||
- A disabled calendar that still holds events: events simply vanish from
|
||||
views/search until re-enabled (no data touched — it's a filter, not a delete).
|
||||
- Deep links / notifications pointing at an event in a disabled calendar still
|
||||
open its detail screen — detail is a direct id lookup, not an `instances()`
|
||||
query, so disabling never strands an existing link.
|
||||
|
||||
## Reminders — defaults & delivery reliability *(shipped v2.6.0; built on `feat/default-reminders`)*
|
||||
|
||||
Two themes bundled because both are "make reminders trustworthy" — the core of
|
||||
the "Calendula is your only calendar app" promise.
|
||||
@@ -414,18 +469,20 @@ it directly undermines the feature's premise, so it rides in here.
|
||||
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||
are notorious for suppressing it).
|
||||
|
||||
### Open decisions (resolve before building)
|
||||
### Decisions made (as shipped in v2.6.0)
|
||||
|
||||
1. Self-schedule via `AlarmManager` vs trust the provider broadcast
|
||||
(reliability vs simplicity + battery cost).
|
||||
2. All-day reminder representation (minutes-before vs absolute time-of-day).
|
||||
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
|
||||
a list inside the Notifications sub-page).
|
||||
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
|
||||
battery-optimisation exemption is the reliability lever (simplicity + battery
|
||||
cost won over the exact-alarm subsystem).
|
||||
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
|
||||
time-of-day ("9am the day before") deferred — see the deferred list above.
|
||||
3. **Per-calendar overrides live in the Notifications sub-page** (override list),
|
||||
governing timed events only.
|
||||
|
||||
### Later (round two)
|
||||
### Round two
|
||||
|
||||
- Snooze + dismiss actions on the notification (snooze needs an
|
||||
exact-alarm / WorkManager decision) — Tier 4 #13.
|
||||
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
|
||||
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
|
||||
|
||||
## Sharing & interop
|
||||
|
||||
@@ -438,8 +495,8 @@ it directly undermines the feature's premise, so it rides in here.
|
||||
## Platform & launchers
|
||||
|
||||
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** —
|
||||
optional quick-settings tile still open
|
||||
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
|
||||
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
|
||||
|
||||
## Quality & reliability
|
||||
|
||||
@@ -455,9 +512,10 @@ it directly undermines the feature's premise, so it rides in here.
|
||||
Beyond classic calendar-client scope; discussed, deliberately not planned
|
||||
in detail yet:
|
||||
|
||||
- **Contact address picker** for the location field via the system picker
|
||||
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS,
|
||||
fits the privacy story. Same mechanism later for picking emails.
|
||||
- ~~**Contact address picker** for the location field via the system picker
|
||||
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
|
||||
the privacy story. The same no-permission mechanism was then reused for the
|
||||
attendee email picker (v2.8.0).
|
||||
- **OSM address autocomplete** in the location field (type "Brandenburger
|
||||
Tor" → tap suggestion → resolved address inserted). Backend would be
|
||||
Photon (Nominatim's public policy forbids autocomplete). **Requires the
|
||||
|
||||
177
CHANGELOG.md
177
CHANGELOG.md
@@ -5,6 +5,168 @@ 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]
|
||||
|
||||
### Added
|
||||
- Set more than one default reminder per calendar. A calendar's default
|
||||
reminders — and the global defaults under Settings → Notifications — can now
|
||||
hold several lead times instead of just one, so new events can start with, say,
|
||||
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||
multi-select; per-calendar overrides can still inherit the global default or
|
||||
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||
|
||||
### Fixed
|
||||
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||
this event** and saving a change to one event in a repeating series silently
|
||||
did nothing — the change was rejected and the edit form simply reappeared with
|
||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||
lands on just that one event and leaves the rest of the series untouched
|
||||
([#16]).
|
||||
|
||||
## [2.12.0] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- See past events your way. Two new settings change how events that have already
|
||||
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||
(Settings → Appearance) separately fades finished events in the month and week
|
||||
views. Both are off by default, an event only counts as finished once it has
|
||||
actually ended (events still in progress are never dimmed), and the lists
|
||||
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||
([#12]).
|
||||
|
||||
### Changed
|
||||
- Readable titles on overlapping events. In the week and day views, events that
|
||||
overlap split a day into slim columns where the title used to be clipped to a
|
||||
character or two. The title now wraps across as many lines as the block can
|
||||
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||
for the suggestion ([#13]).
|
||||
|
||||
## [2.11.2] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||
the cursor in the title field and opens the keyboard right away, so you can type
|
||||
the name without an extra tap. It's on by default and only affects creating an
|
||||
event — editing never grabs focus — and a new **Focus title on new event**
|
||||
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||
suggestion ([#10]).
|
||||
|
||||
## [2.11.1] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- Calendula can now be set as your default calendar app. It registers the
|
||||
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||
that day. Android has no way for an app to make itself the default, so you pick
|
||||
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||
|
||||
## [2.11.0] — 2026-06-27
|
||||
|
||||
### Added
|
||||
- Start the week on any day. The **Week starts on** setting (Settings →
|
||||
Appearance) now offers every weekday — not just Monday or Sunday — alongside
|
||||
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
|
||||
([#3]).
|
||||
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
|
||||
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
|
||||
follow the system setting automatically. It applies everywhere times appear —
|
||||
the week and day timelines, agenda, event details, search and reminders — so
|
||||
the format is now consistent across the whole app. Thanks to @zmaherdev for
|
||||
the suggestion ([#6]).
|
||||
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
|
||||
Appearance) draws a faint separator at each hour in the week and day views,
|
||||
making it easier to see when events start and end. Off by default. Thanks to
|
||||
@zmaherdev for the suggestion ([#5]).
|
||||
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
|
||||
range** and **Agenda widget range** settings (Settings → Appearance) let the
|
||||
agenda screen and its home-screen widget each show just today, the rest of this
|
||||
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
|
||||
days — "This week" follows your week-start. The agenda also gains a bar at the
|
||||
top naming the exact dates in view, with a button to switch the range just for
|
||||
the current session (it resets when you reopen the app); the bar can be turned
|
||||
off. Thanks to @zmaherdev for the suggestion ([#4]).
|
||||
- Automatic backup of local calendars. A new **Automatic backup** option
|
||||
(Settings → Calendars → Backup) periodically exports your local calendars to an
|
||||
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
|
||||
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
|
||||
is a one-way export and never touches your synced accounts. Thanks to @shield
|
||||
for the discussion that inspired it ([#7], [#8]).
|
||||
- Field icons in the event-form settings. Each optional-field toggle (Settings →
|
||||
Event form) now shows the same icon the field uses in the new-event form, so
|
||||
the list is easier to scan.
|
||||
- Help translate Calendula. A **Help translate** link at the top of **Settings →
|
||||
App language** opens the project's Weblate, where you can add or improve a
|
||||
language in your browser — no coding needed. Contributions in any language are
|
||||
welcome.
|
||||
|
||||
### Changed
|
||||
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
||||
agenda), and the agenda's empty state now reads "You're all caught up".
|
||||
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
|
||||
into collapsible, source-branded cards — each account shows its app's icon —
|
||||
with a per-account menu to enable or disable all of its calendars at once or
|
||||
open the account in its source app.
|
||||
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
|
||||
moved up with the other global reminder options, and the per-calendar reminder
|
||||
overrides now fold into a single expandable section.
|
||||
- Moved **Add Quick Settings tile** out of the event-form section to its own
|
||||
top-level entry in Settings.
|
||||
|
||||
## [2.10.0] — 2026-06-25
|
||||
|
||||
### Added
|
||||
- Turn a calendar off across the whole app. Settings → Calendars now has a
|
||||
switch on every calendar — both your own and synced ones. Switching one off
|
||||
removes it everywhere: its events disappear from all views and search, and it
|
||||
drops out of the drawer's hide/show filter, the event-form calendar picker and
|
||||
the import target picker. Unlike hiding (the quick per-view checkbox in the
|
||||
drawer), a disabled calendar leaves the app entirely until you turn it back on.
|
||||
Nothing is deleted and no other app is affected — it's a Calendula-only view
|
||||
choice — so you can re-enable it any time from the same screen, where disabled
|
||||
calendars stay listed but dimmed.
|
||||
- An optional way to support development. Settings → About now has a "Support
|
||||
development" button that opens Ko-fi in your browser. It's a plain donation
|
||||
link with no perks attached, and it needs no new permissions — Calendula still
|
||||
has no internet access of its own and just hands the link to your browser.
|
||||
|
||||
## [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
|
||||
@@ -572,3 +734,18 @@ 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
|
||||
[#3]: https://codeberg.org/jlmakiola/calendula/issues/3
|
||||
[#4]: https://codeberg.org/jlmakiola/calendula/issues/4
|
||||
[#5]: https://codeberg.org/jlmakiola/calendula/issues/5
|
||||
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
||||
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||
|
||||
65
README.md
65
README.md
@@ -15,6 +15,12 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||
|
||||
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="19%" alt="Week view">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||
@@ -62,19 +68,27 @@ database, no sync stack reinvented.
|
||||
|
||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||
expressive motion and shapes, light/dark theme
|
||||
- German and English UI, per-app language setting
|
||||
- German and English UI, per-app language setting — and [open to community
|
||||
translations](#-translations)
|
||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||
never leaves the device
|
||||
|
||||
## 📦 Install
|
||||
|
||||
Calendula ships through a self-hosted F-Droid repository; every version tag
|
||||
is built, signed, and published there automatically.
|
||||
### F-Droid (recommended)
|
||||
|
||||
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
|
||||
Store, …).
|
||||
2. Add the repository — open this link on your phone, or paste it under
|
||||
*Settings → Repositories → Add*:
|
||||
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
||||
just search for **Calendula** in any F-Droid client, or
|
||||
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||
|
||||
### Self-hosted repo (latest builds)
|
||||
|
||||
New versions are built, signed, and published to a self-hosted repository the
|
||||
moment each tag lands — usually a few days ahead of the official repo, which
|
||||
rebuilds on F-Droid's own schedule. Add it for the freshest builds:
|
||||
|
||||
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
|
||||
link below on your phone):
|
||||
|
||||
```
|
||||
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
|
||||
@@ -84,36 +98,27 @@ is built, signed, and published there automatically.
|
||||
fingerprint (SHA-256):
|
||||
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
||||
|
||||
3. Refresh, search for **Calendula**, install. Updates arrive like any
|
||||
other F-Droid app.
|
||||
2. Refresh, search for **Calendula**, install.
|
||||
|
||||
Alternatively, build from source — see below.
|
||||
Both channels share the same signing key, so you can switch between them
|
||||
without reinstalling. Or build from source — see below.
|
||||
|
||||
## 🛠 Building
|
||||
## 📚 Documentation
|
||||
|
||||
Requires Android SDK 36+ and JDK 17. The Gradle wrapper is checked in:
|
||||
- **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug # debug APK
|
||||
./gradlew test # JVM unit tests
|
||||
./gradlew lint # Android lint
|
||||
```
|
||||
## 🌍 Translations
|
||||
|
||||
If your default JDK is not 17, set `JAVA_HOME` explicitly.
|
||||
Calendula ships in German and English, and you're warmly invited to add your
|
||||
language. Translations are managed on a self-hosted **Weblate**:
|
||||
|
||||
## 🏗 Architecture
|
||||
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||
|
||||
Single-activity Compose app, layered `UI → Repository → DataSource →
|
||||
CalendarContract`, observer-driven refresh, JVM-first tests. The full tour —
|
||||
including the recurring-write and reminder pipelines — lives in
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## 🗺 Roadmap
|
||||
|
||||
Shipped: read (v1.0), write (v1.1–v2.0), reminder delivery (v1.4).
|
||||
Next up: power-user features — widget, search, tablet layouts. The living
|
||||
roadmap is in [.planning/ROADMAP.md](.planning/ROADMAP.md), the release
|
||||
history in [CHANGELOG.md](CHANGELOG.md).
|
||||
No coding needed — register on the Weblate server, pick (or request) a language,
|
||||
and translate the strings in your browser. You can also reach this link in the
|
||||
app from the top of **Settings → App language**.
|
||||
|
||||
## 📜 License
|
||||
|
||||
|
||||
@@ -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 = 20800
|
||||
versionName = "2.8.0"
|
||||
versionCode = 21200
|
||||
versionName = "2.12.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -90,6 +90,8 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||
@@ -152,6 +154,9 @@ dependencies {
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.androidx.documentfile)
|
||||
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
|
||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
|
||||
debug build paints the adaptive-icon background burnt orange instead, so the
|
||||
debug icon reads at a glance as "not the real app" on the home screen. The
|
||||
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
|
||||
-->
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFB23B00</color>
|
||||
</resources>
|
||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-build resource overrides. Merged on top of src/main for the `debug`
|
||||
build type only (release/releaseTest keep the production values), so the
|
||||
debug app is unmistakable on the launcher: its own label, alongside the
|
||||
real app thanks to the `.debug` applicationId suffix.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Calendula Debug</string>
|
||||
</resources>
|
||||
@@ -39,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"
|
||||
@@ -58,6 +59,35 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Be selectable as the system calendar app. Android has no API for
|
||||
an app to make itself the default, so registering the filters
|
||||
launchers and the OS use is what lets the user pick Calendula
|
||||
from the system chooser when a date action fires (issue #9).
|
||||
APP_CALENDAR is the "open the calendar app" action; the VIEW
|
||||
filters below catch a tapped date. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.APP_CALENDAR" />
|
||||
</intent-filter>
|
||||
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
|
||||
time Uri (content://com.android.calendar/time/<epochMillis>);
|
||||
some surfaces use the time/epoch mime type. We open the day view
|
||||
on that date (MainActivity.calendarTimeDateOrNull). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:scheme="content"
|
||||
android:host="com.android.calendar"
|
||||
android:pathPrefix="/time" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="time/epoch" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Application entry point. Registered as android:name=".CalendulaApp"
|
||||
@@ -16,5 +24,24 @@ class CalendulaApp : Application() {
|
||||
// Install first thing so startup crashes are captured too (privacy-
|
||||
// respecting, on-device; the user submits the report by hand).
|
||||
CrashReporter.install(this)
|
||||
reconcileAutoBackup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the scheduled auto-backup work back in line with the saved settings
|
||||
* on every launch — re-arms it after a reinstall and, crucially, cancels any
|
||||
* orphaned work once backup has been turned off.
|
||||
*/
|
||||
private fun reconcileAutoBackup() {
|
||||
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
val prefs = deps.settingsPrefs()
|
||||
BackupScheduler.apply(
|
||||
context = this@CalendulaApp,
|
||||
enabled = prefs.autoBackupEnabled.first(),
|
||||
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = prefs.autoBackupFolderUri.first() != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.platform.LocalContext
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -20,14 +24,22 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@@ -80,19 +92,36 @@ class MainActivity : AppCompatActivity() {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
// The app-wide clock convention: the time-format preference resolved
|
||||
// against the device's 24-hour system setting, provided once here so
|
||||
// every time label reads it via LocalUse24HourFormat.
|
||||
val context = LocalContext.current
|
||||
val use24Hour = remember(settings.timeFormat, context) {
|
||||
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
}
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
// mistaken for the production app; compiled out of release.
|
||||
if (BuildConfig.DEBUG) DebugRibbon()
|
||||
}
|
||||
pendingCrashReport?.let { report ->
|
||||
CrashReportDialog(
|
||||
report = report,
|
||||
@@ -137,21 +166,64 @@ class MainActivity : AppCompatActivity() {
|
||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
else -> null
|
||||
} ?: return null
|
||||
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
|
||||
if (uri.host == CALENDAR_PROVIDER_HOST) return null
|
||||
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
|
||||
/**
|
||||
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||
* <epochMillis>`. Null for any other intent. The matching manifest filter is
|
||||
* what lets users pick Calendula from the system calendar chooser (issue #9).
|
||||
*/
|
||||
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "time") return null
|
||||
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return Instant.fromEpochMilliseconds(millis)
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
}
|
||||
|
||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||
// the day view rooted over the default home view (OpenDate source = null).
|
||||
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||
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(
|
||||
@@ -162,12 +234,20 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The calendar provider's authority/host. A date tap arrives as
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||
|
||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||
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 +270,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 {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package de.jeanlucmakiola.calendula.data.backup
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Schedules and runs the automatic periodic `.ics` backup of local calendars
|
||||
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
|
||||
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
|
||||
* everything is local provider reads + a local file write.
|
||||
*/
|
||||
object BackupScheduler {
|
||||
|
||||
private const val WORK_NAME = "auto-backup"
|
||||
private const val WORK_NAME_NOW = "auto-backup-now"
|
||||
|
||||
/**
|
||||
* Reconcile the scheduled work with the current settings: enqueue a unique
|
||||
* periodic request when backup is on and a folder is set, otherwise cancel
|
||||
* it. WorkManager persists the request across reboots on its own.
|
||||
*
|
||||
* The first run is delayed by one interval so it never overlaps an immediate
|
||||
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
|
||||
*/
|
||||
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (!enabled || !hasFolder) {
|
||||
workManager.cancelUniqueWork(WORK_NAME)
|
||||
// Also cancel any pending immediate run — otherwise a failing run-now
|
||||
// keeps retrying after the user has turned backup off.
|
||||
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||
return
|
||||
}
|
||||
// WorkManager's own floor is 15 min; the UI floors the user choice at 30.
|
||||
val interval = intervalMinutes.coerceAtLeast(15L)
|
||||
val request = PeriodicWorkRequestBuilder<BackupWorker>(interval, TimeUnit.MINUTES)
|
||||
.setInitialDelay(interval, TimeUnit.MINUTES)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one export immediately (e.g. right after enabling or changing the
|
||||
* folder) for instant feedback. Unique + REPLACE so rapid taps coalesce into
|
||||
* a single run rather than racing each other.
|
||||
*/
|
||||
fun runNow(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_NAME_NOW,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
OneTimeWorkRequestBuilder<BackupWorker>().build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
|
||||
* overwriting the previous file. Pulls its collaborators through a Hilt
|
||||
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
|
||||
* no custom factory / Application wiring needed. Records the outcome for the
|
||||
* settings status line, and notifies after repeated failures.
|
||||
*/
|
||||
class BackupWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun settingsPrefs(): SettingsPrefs
|
||||
fun repository(): CalendarRepository
|
||||
fun exporter(): IcsExporter
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
val prefs = deps.settingsPrefs()
|
||||
// Respect the toggle even for already-queued work: if backup was turned
|
||||
// off, no-op (and don't retry) so a lingering run can't revive itself.
|
||||
if (!prefs.autoBackupEnabled.first()) return Result.success()
|
||||
val folder = prefs.autoBackupFolderUri.first()
|
||||
?: return Result.failure() // nothing to write to — leave scheduling to settings
|
||||
val now = System.currentTimeMillis()
|
||||
return try {
|
||||
val events = deps.repository().exportEvents()
|
||||
val content = IcsWriter().writeCalendar(events, Clock.System.now())
|
||||
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
|
||||
prefs.recordAutoBackupRun(success = true, atMillis = now)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
prefs.recordAutoBackupRun(success = false, atMillis = now)
|
||||
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
|
||||
notifyFailure(applicationContext)
|
||||
}
|
||||
Log.w(TAG, "Automatic backup failed", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyFailure(context: Context) {
|
||||
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!canPost) return
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.backup_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = context.getString(R.string.backup_channel_description) },
|
||||
)
|
||||
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
val tap = launch?.let {
|
||||
PendingIntent.getActivity(
|
||||
context, 0, it,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.backup_failed_title))
|
||||
.setContentText(context.getString(R.string.backup_failed_text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.apply { tap?.let(::setContentIntent) }
|
||||
.build()
|
||||
try {
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Could not post backup-failure notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BACKUP_FILE_NAME = "calendula-backup.ics"
|
||||
private const val FAILURE_NOTIFY_THRESHOLD = 2
|
||||
private const val CHANNEL_ID = "backup"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val TAG = "BackupWorker"
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
@@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
.reQuery { dataSource.calendars() }
|
||||
.flowOn(io)
|
||||
|
||||
// Instances are filtered by the app-side hidden-calendar set (M3): an event
|
||||
// is dropped whenever the user has hidden its calendar. Re-runs when the
|
||||
// provider ticks *or* the hidden set changes — toggling a calendar in the
|
||||
// filter sheet updates every view immediately. [calendars] stays unfiltered
|
||||
// so the filter sheet can list and re-enable hidden calendars.
|
||||
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
||||
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
||||
// calendar. Re-runs when the provider ticks *or* either set changes —
|
||||
// toggling a calendar in the filter sheet or the calendar manager updates
|
||||
// every view immediately. [calendars] stays unfiltered so those screens can
|
||||
// list and re-enable hidden/disabled calendars.
|
||||
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||
combine(
|
||||
ticks
|
||||
@@ -71,10 +73,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
)
|
||||
},
|
||||
prefs.hiddenCalendarIds,
|
||||
) { instances, hidden ->
|
||||
if (hidden.isEmpty()) instances
|
||||
else instances.filterNot { it.calendarId in hidden }
|
||||
}.flowOn(io)
|
||||
prefs.disabledCalendarIds,
|
||||
) { instances, hidden, disabled ->
|
||||
val excluded = hidden + disabled
|
||||
if (excluded.isEmpty()) instances
|
||||
else instances.filterNot { it.calendarId in excluded }
|
||||
}
|
||||
// hidden and disabled both derive from one DataStore, so toggling
|
||||
// either makes both re-emit and combine briefly surfaces the same
|
||||
// list twice — collapse the duplicate so views don't re-render for it.
|
||||
.distinctUntilChanged()
|
||||
.flowOn(io)
|
||||
|
||||
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||
@@ -82,9 +91,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
|
||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||
if (query.isBlank()) return@withContext emptyList()
|
||||
val hidden = prefs.hiddenCalendarIds.first()
|
||||
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
|
||||
dataSource.searchEvents(query)
|
||||
.let { if (hidden.isEmpty()) it else it.filterNot { e -> e.calendarId in hidden } }
|
||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
||||
}
|
||||
|
||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
|
||||
@@ -118,8 +118,14 @@ internal fun buildEventUpdateValues(
|
||||
* provider clone the series row and apply these on top. Unlike the series
|
||||
* update there is no dirty check — the exception is a fresh row, so every
|
||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||
* clone starts from the parent's values). An exception is a single event:
|
||||
* DTEND, never RRULE/DURATION.
|
||||
* clone starts from the parent's values).
|
||||
*
|
||||
* The occurrence's length travels as DURATION, never DTEND: the provider
|
||||
* rejects DTEND on an exception outright (`CalendarProvider2`:
|
||||
* "Exceptions can't overwrite dtend") and derives the instance end from
|
||||
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||
* "only this event" fail on-device (Codeberg #16).
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
@@ -131,7 +137,7 @@ internal fun buildOccurrenceExceptionValues(
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
@@ -28,6 +29,36 @@ class IcsExporter @Inject constructor(
|
||||
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [content] to [fileName] inside the persisted SAF tree [folder],
|
||||
* overwriting it if it already exists (the automatic-backup destination).
|
||||
* Requires a persisted write grant on [folder]. Throws on failure.
|
||||
*/
|
||||
fun writeToFolder(folder: Uri, fileName: String, content: String) {
|
||||
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||
?: throw IOException("Backup folder is not accessible")
|
||||
if (!dir.exists() || !dir.canWrite()) {
|
||||
throw IOException("Backup folder is missing or not writable")
|
||||
}
|
||||
// Reuse the canonical file if present and clean up any "name (1).ics"
|
||||
// duplicates SAF may have created if two runs ever raced — so we always
|
||||
// converge on a single overwritten file.
|
||||
val base = fileName.substringBeforeLast('.')
|
||||
val ext = fileName.substringAfterLast('.', "")
|
||||
var target: DocumentFile? = null
|
||||
for (child in dir.listFiles()) {
|
||||
val name = child.name ?: continue
|
||||
when {
|
||||
name == fileName -> target = child
|
||||
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
|
||||
}
|
||||
}
|
||||
val file = target
|
||||
?: dir.createFile(MIME_CALENDAR, fileName)
|
||||
?: throw IOException("Could not create backup file in the chosen folder")
|
||||
writeDocument(file.uri, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage [content] in a private cache file and return a shareable content
|
||||
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||
@@ -42,5 +73,6 @@ class IcsExporter @Inject constructor(
|
||||
|
||||
private companion object {
|
||||
const val SHARE_DIR = "shared_ics"
|
||||
const val MIME_CALENDAR = "text/calendar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,31 @@ class CalendarPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-side preference for "calendars the user has disabled in this app" — a
|
||||
* heavier level than [hiddenCalendarIds]. A disabled calendar is removed from
|
||||
* every surface (drawer filter, event-form picker, import picker) and its
|
||||
* events never appear; it stays listed only in Settings → Calendars so it can
|
||||
* be re-enabled. Stored exactly like the hidden set; never touches the
|
||||
* system's VISIBLE/SYNC_EVENTS flags, so other calendar apps are unaffected.
|
||||
*/
|
||||
val disabledCalendarIds: Flow<Set<Long>> = store.data.map { prefs ->
|
||||
prefs[DISABLED_IDS_KEY].orEmpty()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
suspend fun setDisabledCalendarIds(ids: Set<Long>) {
|
||||
store.edit { prefs ->
|
||||
if (ids.isEmpty()) {
|
||||
prefs.remove(DISABLED_IDS_KEY)
|
||||
} else {
|
||||
prefs[DISABLED_IDS_KEY] = ids.sorted().joinToString(",")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar the user last created an event in; preselected in the
|
||||
* event form. Null until the first event is created.
|
||||
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
|
||||
|
||||
companion object {
|
||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||
internal val DISABLED_IDS_KEY = stringPreferencesKey("disabled_calendar_ids")
|
||||
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -18,18 +23,52 @@ import javax.inject.Singleton
|
||||
/** Light/dark override. SYSTEM follows the device setting. */
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
/** Week-start override. AUTO derives the first day from the active locale. */
|
||||
enum class WeekStartPref { AUTO, MONDAY, SUNDAY }
|
||||
/**
|
||||
* Week-start override. [Auto] derives the first day from the active locale;
|
||||
* [Day] pins a specific weekday (any of the seven).
|
||||
*/
|
||||
sealed interface WeekStartPref {
|
||||
data object Auto : WeekStartPref
|
||||
data class Day(val day: DayOfWeek) : WeekStartPref
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the preference to a concrete first-day-of-week. AUTO reads the
|
||||
* locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||
* Clock convention for time-of-day labels. AUTO follows the device's 24-hour
|
||||
* system setting; the others force a 12- or 24-hour clock app-wide.
|
||||
*/
|
||||
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
||||
|
||||
/**
|
||||
* How the Agenda screen treats events that have already finished today: [SHOW]
|
||||
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
|
||||
* them from the list entirely.
|
||||
*/
|
||||
enum class PastEventDisplay { SHOW, DIM, HIDE }
|
||||
|
||||
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
|
||||
fun parsePastEventDisplay(
|
||||
stored: String?,
|
||||
default: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
|
||||
|
||||
/**
|
||||
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
|
||||
* device's `DateFormat.is24HourFormat` value).
|
||||
*/
|
||||
fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
|
||||
TimeFormatPref.AUTO -> systemIs24Hour
|
||||
TimeFormatPref.TWELVE_HOUR -> false
|
||||
TimeFormatPref.TWENTY_FOUR_HOUR -> true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the preference to a concrete first-day-of-week. [WeekStartPref.Auto]
|
||||
* reads the locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||
*/
|
||||
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
|
||||
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
|
||||
is WeekStartPref.Day -> day
|
||||
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
|
||||
WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +93,7 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
|
||||
prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO)
|
||||
parseWeekStart(prefs[WEEK_START_KEY])
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
@@ -66,7 +105,101 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
||||
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
||||
}
|
||||
|
||||
/** Clock convention for time-of-day labels (v2.11). Defaults to AUTO (system). */
|
||||
val timeFormat: Flow<TimeFormatPref> = store.data.map { prefs ->
|
||||
prefs[TIME_FORMAT_KEY].toEnum(TimeFormatPref.AUTO)
|
||||
}
|
||||
|
||||
suspend fun setTimeFormat(pref: TimeFormatPref) {
|
||||
store.edit { it[TIME_FORMAT_KEY] = pref.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline draws a faint separator line at each hour
|
||||
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
|
||||
*/
|
||||
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setShowHourLines(enabled: Boolean) {
|
||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How the Agenda screen treats events that already ended today. Defaults to
|
||||
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
|
||||
* or hiding.
|
||||
*/
|
||||
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
|
||||
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
|
||||
}
|
||||
|
||||
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the month/week grids fade events that have already finished.
|
||||
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
|
||||
*/
|
||||
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setDimCompletedEvents(enabled: Boolean) {
|
||||
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
* widget's [agendaWidgetRange].
|
||||
*/
|
||||
val agendaScreenRange: Flow<AgendaRange> = store.data.map { prefs ->
|
||||
parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month)
|
||||
}
|
||||
|
||||
suspend fun setAgendaScreenRange(range: AgendaRange) {
|
||||
store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
|
||||
/** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */
|
||||
val agendaWidgetRange: Flow<AgendaRange> = store.data.map { prefs ->
|
||||
parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month)
|
||||
}
|
||||
|
||||
suspend fun setAgendaWidgetRange(range: AgendaRange) {
|
||||
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||
* the session range switcher (v2.11). Default ON.
|
||||
*/
|
||||
val agendaShowRangeBar: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAgendaShowRangeBar(enabled: Boolean) {
|
||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +220,20 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening the new-event form focuses the title field and raises the
|
||||
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||
* gets a title, so this saves a tap; users who set the time/calendar first
|
||||
* can turn it off. Only the create form auto-focuses — editing never does.
|
||||
*/
|
||||
val autofocusEventTitle: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAutofocusEventTitle(enabled: Boolean) {
|
||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||
* for users whose only calendar app this is, reminders are essential; the
|
||||
@@ -129,35 +276,36 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reminder lead time (minutes before start) prefilled on new
|
||||
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
||||
* as the factory default so existing users aren't surprised by reminders they
|
||||
* never asked for. Stored as a string so "none" is distinct from a numeric
|
||||
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
||||
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||
* The default reminder lead times (minutes before start) prefilled on new
|
||||
* **timed** events. The empty list = no default reminder — the prior
|
||||
* behaviour, kept as the factory default so existing users aren't surprised by
|
||||
* reminders they never asked for. Stored as a comma-joined list of minutes, or
|
||||
* "none"/empty for no reminder (also the unset state). A legacy single value
|
||||
* ("30") parses transparently to a one-element list. Per-calendar overrides in
|
||||
* [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||
*/
|
||||
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
||||
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_REMINDER_KEY].toReminderList()
|
||||
}
|
||||
|
||||
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||
suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reminder lead time prefilled on new **all-day** events, in
|
||||
* The default reminder lead times prefilled on new **all-day** events, in
|
||||
* minutes before the start of the day. All-day events want day-scale lead
|
||||
* times ("1 day before"), so they have their own default rather than reusing
|
||||
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
||||
* to all-day events — they always use this global value.
|
||||
* the timed one. Empty list = no default. Per-calendar overrides do **not**
|
||||
* apply to all-day events — they always use this global value.
|
||||
*/
|
||||
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
||||
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
|
||||
}
|
||||
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,13 +342,13 @@ class SettingsPrefs @Inject constructor(
|
||||
/**
|
||||
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||
* timed default for its new events: a `null` value means "no reminder", an int
|
||||
* means that lead time. A calendar **absent** from the map inherits the global
|
||||
* default. Serialised as `id=value;id=value`, with `none` for an explicit
|
||||
* no-reminder override. (All-day events ignore this and use
|
||||
* [defaultAllDayReminderMinutes].)
|
||||
* timed default for its new events: an **empty** list means "no reminder", a
|
||||
* non-empty list means those lead times. A calendar **absent** from the map
|
||||
* inherits the global default. Serialised as `id=value;id=value`, where a value
|
||||
* is a comma-joined minute list or `none` for an explicit no-reminder override.
|
||||
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||
*/
|
||||
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
@@ -217,7 +365,7 @@ class SettingsPrefs @Inject constructor(
|
||||
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||
* inherit the global all-day default; present null = no reminder).
|
||||
*/
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
@@ -233,6 +381,52 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
/** Whether periodic automatic `.ics` export of local calendars is on. Default OFF. */
|
||||
val autoBackupEnabled: Flow<Boolean> = store.data.map { it[AUTO_BACKUP_ENABLED_KEY] ?: false }
|
||||
|
||||
suspend fun setAutoBackupEnabled(enabled: Boolean) {
|
||||
store.edit { it[AUTO_BACKUP_ENABLED_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** Export interval in minutes. Default daily; floored at [MIN_BACKUP_INTERVAL]. */
|
||||
val autoBackupIntervalMinutes: Flow<Long> = store.data.map { prefs ->
|
||||
(prefs[AUTO_BACKUP_INTERVAL_KEY] ?: DEFAULT_BACKUP_INTERVAL).coerceAtLeast(MIN_BACKUP_INTERVAL)
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||
store.edit { it[AUTO_BACKUP_INTERVAL_KEY] = minutes.coerceAtLeast(MIN_BACKUP_INTERVAL) }
|
||||
}
|
||||
|
||||
/** Persisted SAF tree Uri of the destination folder, or null if unset. */
|
||||
val autoBackupFolderUri: Flow<String?> = store.data.map { it[AUTO_BACKUP_FOLDER_KEY] }
|
||||
|
||||
suspend fun setAutoBackupFolderUri(uri: String?) {
|
||||
store.edit { prefs ->
|
||||
if (uri == null) prefs.remove(AUTO_BACKUP_FOLDER_KEY) else prefs[AUTO_BACKUP_FOLDER_KEY] = uri
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of the last automatic run, for the settings status line. */
|
||||
val autoBackupStatus: Flow<BackupStatus> = store.data.map { prefs ->
|
||||
BackupStatus(
|
||||
lastRun = prefs[AUTO_BACKUP_LAST_RUN_KEY] ?: 0L,
|
||||
lastSuccess = prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] ?: true,
|
||||
consecutiveFailures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
/** Record an automatic run's outcome; resets the failure streak on success. */
|
||||
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
|
||||
store.edit { prefs ->
|
||||
prefs[AUTO_BACKUP_LAST_RUN_KEY] = atMillis
|
||||
prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] = success
|
||||
val failures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0
|
||||
prefs[AUTO_BACKUP_FAILURES_KEY] = if (success) 0 else failures + 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -244,7 +438,16 @@ 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 AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
@@ -266,76 +469,128 @@ class SettingsPrefs @Inject constructor(
|
||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||
internal val DEFAULT_FORM_FIELDS =
|
||||
setOf(EventFormField.Location, EventFormField.Description)
|
||||
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||
internal val AUTO_BACKUP_LAST_RUN_KEY = longPreferencesKey("auto_backup_last_run")
|
||||
internal val AUTO_BACKUP_LAST_SUCCESS_KEY = booleanPreferencesKey("auto_backup_last_success")
|
||||
internal val AUTO_BACKUP_FAILURES_KEY = intPreferencesKey("auto_backup_failures")
|
||||
/** Default automatic-backup interval: daily. */
|
||||
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||
const val MIN_BACKUP_INTERVAL = 30L
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||
data class BackupStatus(
|
||||
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||
val lastRun: Long,
|
||||
val lastSuccess: Boolean,
|
||||
val consecutiveFailures: Int,
|
||||
)
|
||||
|
||||
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||
sealed interface CalendarReminderOverride {
|
||||
/** No override — the calendar uses the global default. */
|
||||
data object Inherit : CalendarReminderOverride
|
||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||
data object None : CalendarReminderOverride
|
||||
/** A specific lead time in minutes before the event start. */
|
||||
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
||||
/** Specific lead times in minutes before the event start (non-empty). */
|
||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead time to prefill on a new event: the matching per-calendar override
|
||||
* The lead times to prefill on a new event: the matching per-calendar override
|
||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
||||
* it can be unit-tested.
|
||||
* events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
|
||||
* Pure so it can be unit-tested.
|
||||
*/
|
||||
fun resolveDefaultReminder(
|
||||
timedGlobal: Int?,
|
||||
allDayGlobal: Int?,
|
||||
timedOverrides: Map<Long, Int?>,
|
||||
allDayOverrides: Map<Long, Int?>,
|
||||
timedGlobal: List<Int>,
|
||||
allDayGlobal: List<Int>,
|
||||
timedOverrides: Map<Long, List<Int>>,
|
||||
allDayOverrides: Map<Long, List<Int>>,
|
||||
calendarId: Long?,
|
||||
isAllDay: Boolean,
|
||||
): Int? {
|
||||
): List<Int> {
|
||||
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||
overrides[calendarId]
|
||||
overrides.getValue(calendarId)
|
||||
} else {
|
||||
global
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
||||
private fun MutableMap<Long, Int?>.applyOverride(
|
||||
/**
|
||||
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
||||
* key; [None] and an empty [Minutes] both store the empty list).
|
||||
*/
|
||||
private fun MutableMap<Long, List<Int>>.applyOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
) {
|
||||
when (override) {
|
||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||
CalendarReminderOverride.None -> put(calendarId, null)
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
||||
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||
private const val WEEK_START_AUTO = "AUTO"
|
||||
|
||||
private fun WeekStartPref.storageValue(): String = when (this) {
|
||||
WeekStartPref.Auto -> WEEK_START_AUTO
|
||||
is WeekStartPref.Day -> day.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the stored week-start value. "AUTO"/null/garbage → [WeekStartPref.Auto];
|
||||
* a [DayOfWeek] name → [WeekStartPref.Day]. The legacy "MONDAY"/"SUNDAY" enum
|
||||
* values migrate transparently, since both are valid day names.
|
||||
*/
|
||||
private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
|
||||
null, WEEK_START_AUTO -> WeekStartPref.Auto
|
||||
else -> DayOfWeek.entries.firstOrNull { it.name == stored }
|
||||
?.let { WeekStartPref.Day(it) } ?: WeekStartPref.Auto
|
||||
}
|
||||
|
||||
private const val NONE = "none"
|
||||
private const val ENTRY_SEP = ";"
|
||||
private const val KEY_VALUE_SEP = "="
|
||||
private const val LIST_SEP = ","
|
||||
|
||||
private fun String?.toReminderMinutes(): Int? = when (this) {
|
||||
null, "", NONE -> null
|
||||
else -> toIntOrNull()
|
||||
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||
|
||||
/**
|
||||
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
|
||||
* one-element list. Non-numeric parts are dropped defensively.
|
||||
*/
|
||||
private fun String?.toReminderList(): List<Int> = when {
|
||||
this == null || isEmpty() || this == NONE -> emptyList()
|
||||
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
|
||||
}
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
||||
/** Serialise lead times for storage: "none" when empty, else comma-joined. */
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||
if (stored.isNullOrBlank()) return emptyMap()
|
||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||
id to value
|
||||
id to parts[1].toReminderList()
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
||||
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -46,7 +46,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
if (settingsPrefs.remindersEnabled.first()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
due.forEach(notifier::post)
|
||||
due.forEach { notifier.post(it) }
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -14,6 +14,9 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -29,6 +32,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -39,15 +43,18 @@ class ReminderNotifier @Inject constructor(
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
fun post(alert: ReminderAlert) {
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val time = reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
zone = ZoneId.systemDefault(),
|
||||
locale = Locale.getDefault(),
|
||||
is24Hour = is24Hour,
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
@@ -25,6 +26,7 @@ fun reminderTimeText(
|
||||
isAllDay: Boolean,
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
is24Hour: Boolean,
|
||||
): String {
|
||||
if (isAllDay) {
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
@@ -40,16 +42,18 @@ fun reminderTimeText(
|
||||
}
|
||||
}
|
||||
|
||||
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
} else {
|
||||
val dateTimeFormat = DateTimeFormatter
|
||||
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||
.withLocale(locale)
|
||||
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
|
||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||
// applies to the time portion too.
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||
dateTime(begin) + RANGE + dateTime(end)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@ data class EventInstance(
|
||||
val location: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this event has finished relative to [now] — its end is at or before
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
* is *not* considered ended. All-day events end at the exclusive next-midnight,
|
||||
* so they only count as ended once their day is fully over.
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -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,15 +14,22 @@ 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
|
||||
@@ -35,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(
|
||||
@@ -53,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
|
||||
@@ -156,17 +184,32 @@ fun CalendarHost(
|
||||
importForm = null
|
||||
}
|
||||
|
||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
||||
// 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 -> {
|
||||
// Reveal the day view: drop any overlay that would cover it, so
|
||||
// an external date-open doesn't land under an open Settings/form.
|
||||
// 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
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).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 -> {
|
||||
@@ -189,41 +232,61 @@ 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,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = view,
|
||||
onSelectView = onSelectView,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
)
|
||||
// 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
|
||||
@@ -338,3 +401,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,33 @@
|
||||
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]. A null [source] means the request came from outside the app (a
|
||||
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
|
||||
* the default home view instead of a widget's view.
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* How far ahead the agenda (screen or widget) shows events, starting at today.
|
||||
*
|
||||
* Two flavours:
|
||||
* - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days),
|
||||
* [Month] (30 days), [Custom] (1–365 days). These never degenerate.
|
||||
* - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs
|
||||
* through the end of the current week (respecting the week-start preference —
|
||||
* a Monday start means "everything before next Monday"); [ThisMonth] runs
|
||||
* through the last day of the current month. These shrink as the period ends.
|
||||
*/
|
||||
sealed interface AgendaRange {
|
||||
data object Day : AgendaRange
|
||||
data object Week : AgendaRange
|
||||
data object Month : AgendaRange
|
||||
data object ThisWeek : AgendaRange
|
||||
data object ThisMonth : AgendaRange
|
||||
data class Custom(val days: Int) : AgendaRange
|
||||
|
||||
companion object {
|
||||
/** Allowed bounds for a [Custom] day count. */
|
||||
const val MIN_CUSTOM_DAYS = 1
|
||||
const val MAX_CUSTOM_DAYS = 365
|
||||
}
|
||||
}
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* Inclusive number of days the window spans, starting at (and including)
|
||||
* [anchor]. The calendar-aligned ranges depend on the anchor day and, for
|
||||
* [AgendaRange.ThisWeek], the [weekStart] preference.
|
||||
*/
|
||||
fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) {
|
||||
AgendaRange.Day -> 1
|
||||
AgendaRange.Week -> DAYS_PER_WEEK
|
||||
AgendaRange.Month -> 30
|
||||
AgendaRange.ThisWeek -> {
|
||||
// Days elapsed since the week's first day (0 when today *is* the start),
|
||||
// so the window is the rest of the week through the day before it repeats.
|
||||
val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK
|
||||
DAYS_PER_WEEK - sinceWeekStart
|
||||
}
|
||||
AgendaRange.ThisMonth -> {
|
||||
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
|
||||
daysInMonth - anchor.day + 1
|
||||
}
|
||||
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
|
||||
}
|
||||
|
||||
/** Stored representation: a fixed token, or `CUSTOM:<days>`. */
|
||||
fun AgendaRange.storageValue(): String = when (this) {
|
||||
AgendaRange.Day -> "DAY"
|
||||
AgendaRange.Week -> "WEEK"
|
||||
AgendaRange.Month -> "MONTH"
|
||||
AgendaRange.ThisWeek -> "THIS_WEEK"
|
||||
AgendaRange.ThisMonth -> "THIS_MONTH"
|
||||
is AgendaRange.Custom -> "$CUSTOM_PREFIX$days"
|
||||
}
|
||||
|
||||
/** Parse a stored value; unknown/garbage falls back to [default]. */
|
||||
fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when {
|
||||
stored == "DAY" -> AgendaRange.Day
|
||||
stored == "WEEK" -> AgendaRange.Week
|
||||
stored == "MONTH" -> AgendaRange.Month
|
||||
stored == "THIS_WEEK" -> AgendaRange.ThisWeek
|
||||
stored == "THIS_MONTH" -> AgendaRange.ThisMonth
|
||||
stored != null && stored.startsWith(CUSTOM_PREFIX) -> {
|
||||
val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull()
|
||||
if (days != null) {
|
||||
AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS))
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
else -> default
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete span the range covers, starting at [start] through [end]
|
||||
* (inclusive), for a human-readable header:
|
||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
||||
*/
|
||||
fun agendaRangeWindowSummary(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
return when (range) {
|
||||
AgendaRange.Day -> medium.format(javaStart)
|
||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
||||
}
|
||||
}
|
||||
|
||||
private const val CUSTOM_PREFIX = "CUSTOM:"
|
||||
@@ -2,22 +2,26 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Row
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -35,9 +39,13 @@ import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -46,17 +54,27 @@ 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.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
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
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -83,15 +101,18 @@ fun AgendaScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
|
||||
val isOnToday = when (val s = state) {
|
||||
is AgendaUiState.Success -> s.anchor == s.today
|
||||
else -> true
|
||||
}
|
||||
val successState = state as? AgendaUiState.Success
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -134,21 +155,134 @@ fun AgendaScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
AgendaContent(
|
||||
state = state,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showRangePicker) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.agenda_range_override_hint),
|
||||
selected = successState?.range ?: AgendaRange.Month,
|
||||
onSelect = viewModel::setRangeOverride,
|
||||
onDismiss = { showRangePicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||
* range picker as a session-only override. Filled with the primary container
|
||||
* while an override is active, so the temporary state is obvious.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangePill(
|
||||
range: AgendaRange,
|
||||
isOverride: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
val content = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
color = container,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangeBanner(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val window = agendaRangeWindowSummary(range, start, end, locale)
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_range_showing_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "${agendaRangeLabel(range)}, $window",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -158,19 +292,42 @@ private fun AgendaContent(
|
||||
is AgendaUiState.Failure -> Box(modifier) {
|
||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
}
|
||||
is AgendaUiState.Success ->
|
||||
if (state.days.isEmpty()) {
|
||||
is AgendaUiState.Success -> {
|
||||
val now by rememberCurrentMinute()
|
||||
// Hiding drops finished events — and any day they leave empty; dimming
|
||||
// keeps them but fades the row. Recomputed each minute so events fall
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
if (days.isEmpty()) {
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AgendaList(
|
||||
state: AgendaUiState.Success,
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -179,9 +336,9 @@ private fun AgendaList(
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = state.today)
|
||||
AgendaDayHeader(date = day.date, today = today)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
@@ -190,6 +347,8 @@ private fun AgendaList(
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
@@ -221,11 +380,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
@@ -250,7 +412,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.EventAvailable,
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
@@ -261,13 +423,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,15 +489,17 @@ private fun agendaTimeSummary(event: EventInstance): String {
|
||||
val time = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant): String {
|
||||
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return "%02d:%02d".format(t.hour, t.minute)
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
|
||||
@@ -53,5 +53,13 @@ sealed interface AgendaUiState {
|
||||
val anchor: LocalDate,
|
||||
val today: LocalDate,
|
||||
val days: List<AgendaDay>,
|
||||
/** The range currently in effect — the saved default or a session override. */
|
||||
val range: AgendaRange,
|
||||
/** True when [range] is a temporary in-view override of the saved default. */
|
||||
val rangeIsOverride: Boolean,
|
||||
/** Last day the current [range] covers (inclusive), for the range header. */
|
||||
val rangeEnd: LocalDate,
|
||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||
val showRangeBar: Boolean,
|
||||
) : AgendaUiState
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ 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.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -17,10 +20,13 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
@@ -30,16 +36,36 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/** How far ahead the agenda loads events from its anchor day. */
|
||||
internal const val AGENDA_WINDOW_DAYS = 60
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class AgendaViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
// The saved agenda range plus the range-bar visibility toggle.
|
||||
private val agendaSettings = combine(
|
||||
settingsPrefs.agendaScreenRange,
|
||||
settingsPrefs.agendaShowRangeBar,
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
// First day of the week, for the calendar-aligned "this week" range.
|
||||
private val weekStartDay = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
* concern only, so it rides alongside the data state rather than re-querying;
|
||||
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
||||
*/
|
||||
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
@@ -48,14 +74,32 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
val anchor: StateFlow<LocalDate> = _anchor
|
||||
|
||||
val state: StateFlow<AgendaUiState> = _anchor
|
||||
.flatMapLatest { anchor ->
|
||||
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
||||
// A transient, in-view override of the saved agenda range. Held in memory
|
||||
// (not persisted), so it survives view switches and rotation within the
|
||||
// session but resets to the saved default when the app is relaunched.
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
rangeIsOverride = override != null && override != settings.range,
|
||||
weekStart = weekStart,
|
||||
showRangeBar = settings.showBar,
|
||||
)
|
||||
}
|
||||
.flatMapLatest { params ->
|
||||
val window = agendaRange(
|
||||
params.anchor,
|
||||
params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||
zone,
|
||||
)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
repository.instances(window),
|
||||
) { calendars, instances ->
|
||||
buildState(anchor, calendars, instances)
|
||||
buildState(params, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
@@ -75,16 +119,47 @@ class AgendaViewModel @Inject constructor(
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
/** Temporarily override the agenda range for this session (the bottom-left pill). */
|
||||
fun setRangeOverride(range: AgendaRange) {
|
||||
_rangeOverride.value = range
|
||||
}
|
||||
|
||||
private data class AgendaSettings(
|
||||
val range: AgendaRange,
|
||||
val showBar: Boolean,
|
||||
)
|
||||
|
||||
private data class AgendaParams(
|
||||
val anchor: LocalDate,
|
||||
val range: AgendaRange,
|
||||
val rangeIsOverride: Boolean,
|
||||
val weekStart: DayOfWeek,
|
||||
val showRangeBar: Boolean,
|
||||
)
|
||||
|
||||
private fun buildState(
|
||||
anchor: LocalDate,
|
||||
params: AgendaParams,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val anchor = params.anchor
|
||||
val days = groupAgendaDays(anchor, instances, zone)
|
||||
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
today = todayDate,
|
||||
days = days,
|
||||
range = params.range,
|
||||
rangeIsOverride = params.rangeIsOverride,
|
||||
rangeEnd = rangeEnd,
|
||||
showRangeBar = params.showRangeBar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@ import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
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.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -27,25 +30,33 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -59,27 +70,43 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
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.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
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.CollapsingScaffold
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
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.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
@@ -98,8 +125,10 @@ fun CalendarsScreen(
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||
// [editorSession] bumps on every open so the editor's field state resets for
|
||||
@@ -134,14 +163,21 @@ fun CalendarsScreen(
|
||||
CalendarsList(
|
||||
local = calendars.filter { it.isLocal },
|
||||
synced = calendars.filterNot { it.isLocal },
|
||||
disabledIds = disabledIds,
|
||||
error = error,
|
||||
onConsumeError = viewModel::consumeError,
|
||||
backupResult = backupResult,
|
||||
onExportBackup = viewModel::exportBackup,
|
||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||
autoBackup = autoBackup,
|
||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
||||
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
||||
onBack = onBack,
|
||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
onSetDisabled = viewModel::setDisabled,
|
||||
onSetAccountDisabled = viewModel::setAccountDisabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -150,17 +186,28 @@ fun CalendarsScreen(
|
||||
private fun CalendarsList(
|
||||
local: List<CalendarSource>,
|
||||
synced: List<CalendarSource>,
|
||||
disabledIds: Set<Long>,
|
||||
error: Boolean,
|
||||
onConsumeError: () -> Unit,
|
||||
backupResult: BackupResult?,
|
||||
onExportBackup: (android.net.Uri) -> Unit,
|
||||
onConsumeBackupResult: () -> Unit,
|
||||
autoBackup: AutoBackupUiState,
|
||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||
onSetAutoBackupInterval: (Long) -> Unit,
|
||||
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onSetDisabled: (Long, Boolean) -> Unit,
|
||||
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||
// calendar visible by default, the section is collapsible for tidiness).
|
||||
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
|
||||
var localExpanded by remember { mutableStateOf(true) }
|
||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||
|
||||
LaunchedEffect(error) {
|
||||
@@ -176,6 +223,13 @@ private fun CalendarsList(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let(onExportBackup) }
|
||||
|
||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||
// write grant so background runs can keep writing to it.
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(onSetAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
@@ -200,36 +254,51 @@ private fun CalendarsList(
|
||||
onBack = onBack,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) {
|
||||
// Local (device-only) calendars — the calendars the app owns. The
|
||||
// "Add calendar" entry closes the group as its final row.
|
||||
SectionHeader(stringResource(R.string.calendars_local_header))
|
||||
if (local.isEmpty()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
}
|
||||
val localCount = local.size + 1
|
||||
local.forEachIndexed { index, calendar ->
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, localCount),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = stringResource(R.string.calendars_edit_title),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_disable_hint))
|
||||
|
||||
// Local (device-only) calendars — one collapsible group. The header's
|
||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||
// tapping a calendar row opens its editor.
|
||||
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
|
||||
CalendarGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
expanded = localExpanded,
|
||||
bodyHasRows = local.isNotEmpty(),
|
||||
headerDisabled = localDisabled,
|
||||
leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } },
|
||||
manageIcon = Icons.Default.Add,
|
||||
manageLabel = stringResource(R.string.calendars_add),
|
||||
onManage = onAdd,
|
||||
onToggleExpand = { localExpanded = !localExpanded },
|
||||
showToggleAll = local.isNotEmpty(),
|
||||
allEnabled = local.none { it.id in disabledIds },
|
||||
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
|
||||
) {
|
||||
if (local.isEmpty()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
} else {
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
)
|
||||
},
|
||||
onClick = { onEdit(calendar) },
|
||||
)
|
||||
},
|
||||
onClick = { onEdit(calendar) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_add),
|
||||
position = positionOf(local.size, localCount),
|
||||
leading = { AddAvatar() },
|
||||
onClick = onAdd,
|
||||
)
|
||||
|
||||
// Backup — local calendars have no sync, so a .ics export is their only
|
||||
// safety net. Offered only when there is something to back up.
|
||||
@@ -237,42 +306,106 @@ private fun CalendarsList(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
// One connected card: the one-time export on top, then automatic
|
||||
// backup (and its folder/interval rows when on).
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Alone,
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
|
||||
},
|
||||
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showInterval = true },
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Synced calendars — read-only, grouped by account, each with a
|
||||
// per-account "manage in source app" link.
|
||||
// Synced calendars — read-only, grouped by account. Each account is a
|
||||
// collapsible group whose header opens the source app (icon) and toggles
|
||||
// all of its calendars at once (switch).
|
||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||
HintText(stringResource(R.string.calendars_synced_hint))
|
||||
synced
|
||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||
.forEach { (account, cals) ->
|
||||
AccountHeader(account = account, accountType = cals.first().accountType)
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = positionOf(index, cals.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
)
|
||||
val expanded = account !in collapsedAccounts
|
||||
val accountType = cals.first().accountType
|
||||
val accountDisabled = cals.all { it.id in disabledIds }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CalendarGroup(
|
||||
title = account,
|
||||
expanded = expanded,
|
||||
bodyHasRows = true,
|
||||
headerDisabled = accountDisabled,
|
||||
leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } },
|
||||
manageIcon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
manageLabel = stringResource(R.string.calendars_manage_in_app),
|
||||
onManage = {
|
||||
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
||||
},
|
||||
onToggleExpand = {
|
||||
collapsedAccounts = if (expanded) {
|
||||
collapsedAccounts + account
|
||||
} else {
|
||||
collapsedAccounts - account
|
||||
}
|
||||
},
|
||||
showToggleAll = true,
|
||||
allEnabled = cals.none { it.id in disabledIds },
|
||||
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
|
||||
) {
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_add_account),
|
||||
position = Position.Alone,
|
||||
leading = { AddAvatar() },
|
||||
onClick = {
|
||||
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
|
||||
},
|
||||
}
|
||||
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
currentMinutes = autoBackup.intervalMinutes,
|
||||
onConfirm = onSetAutoBackupInterval,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -295,10 +428,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 = {
|
||||
@@ -425,6 +557,30 @@ private fun CalendarEditor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row enable/disable control. Checked = the calendar is shown in the
|
||||
* app; unchecking disables it (events, filters and pickers all drop it) without
|
||||
* touching any provider data. Carries its own content description so the toggle
|
||||
* is self-describing to screen readers even on a dimmed row.
|
||||
*/
|
||||
@Composable
|
||||
private fun EnableSwitch(
|
||||
calendarName: String,
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
val label = stringResource(R.string.calendars_show_in_app_a11y, calendarName)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
modifier = Modifier.semantics { contentDescription = label },
|
||||
)
|
||||
}
|
||||
|
||||
/** Fade a leading element to the M3 disabled emphasis when [disabled]. */
|
||||
private fun dimIf(disabled: Boolean): Modifier =
|
||||
if (disabled) Modifier.alpha(0.38f) else Modifier
|
||||
|
||||
/** Tonal field card matching the event editor's design (icon + content). */
|
||||
@Composable
|
||||
private fun EditorCard(
|
||||
@@ -456,32 +612,178 @@ private fun EditorCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible calendar group rendered as a connected card. The header row is
|
||||
* the card's top (tap to expand/collapse): a [leading] source mark (the account
|
||||
* app's logo, or a device chip for local), the group [title] and a trailing
|
||||
* overflow (⋮) menu holding the account-level actions — enable/disable every
|
||||
* calendar at once (when [showToggleAll]) and the manage/add action
|
||||
* ([manageIcon] / [manageLabel] → [onManage]). The group's calendars render as
|
||||
* the rows below via [body].
|
||||
*
|
||||
* The header keeps the standard row colour; the calendars it reveals sit one tone
|
||||
* darker, so the group reads as a header over nested content. Once [headerDisabled]
|
||||
* (every calendar switched off) the header fades to the disabled emphasis so the
|
||||
* whole group reads as off — not just via the menu. [bodyHasRows] is false when no
|
||||
* calendar rows follow (e.g. an empty local group), so the header stays a
|
||||
* standalone card rather than a top edge with nothing beneath it.
|
||||
*/
|
||||
@Composable
|
||||
private fun AccountHeader(account: String, accountType: String) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
private fun CalendarGroup(
|
||||
title: String,
|
||||
expanded: Boolean,
|
||||
bodyHasRows: Boolean,
|
||||
headerDisabled: Boolean,
|
||||
leading: @Composable () -> Unit,
|
||||
manageIcon: ImageVector,
|
||||
manageLabel: String,
|
||||
onManage: () -> Unit,
|
||||
onToggleExpand: () -> Unit,
|
||||
showToggleAll: Boolean,
|
||||
allEnabled: Boolean,
|
||||
onToggleAll: (Boolean) -> Unit,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
position = if (expanded && bodyHasRows) Position.Top else Position.Alone,
|
||||
dimmed = headerDisabled,
|
||||
leading = leading,
|
||||
trailing = {
|
||||
CalendarGroupMenu(
|
||||
title = title,
|
||||
showToggleAll = showToggleAll,
|
||||
allEnabled = allEnabled,
|
||||
onToggleAll = onToggleAll,
|
||||
manageIcon = manageIcon,
|
||||
manageLabel = manageLabel,
|
||||
onManage = onManage,
|
||||
)
|
||||
},
|
||||
onClick = onToggleExpand,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
Text(
|
||||
text = account,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedButton(onClick = {
|
||||
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
||||
}) {
|
||||
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.calendars_manage_in_app))
|
||||
Column(content = body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account header's overflow (⋮) menu: the two account-level actions that
|
||||
* don't fit on one line — "Enable/Disable all" (when [showToggleAll]) and the
|
||||
* manage/add action. A rounded, tonal dropdown matching the app's surfaces, with
|
||||
* the two actions divided for clear separation.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarGroupMenu(
|
||||
title: String,
|
||||
showToggleAll: Boolean,
|
||||
allEnabled: Boolean,
|
||||
onToggleAll: (Boolean) -> Unit,
|
||||
manageIcon: ImageVector,
|
||||
manageLabel: String,
|
||||
onManage: () -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { open = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.calendars_account_menu_a11y, title),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = open,
|
||||
onDismissRequest = { open = false },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
// A distinct tone + a lifted shadow so the menu reads as floating
|
||||
// above the cards (which sit at surfaceContainerHigh) rather than
|
||||
// blending into them.
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 6.dp,
|
||||
) {
|
||||
if (showToggleAll) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (allEnabled) R.string.calendars_disable_all
|
||||
else R.string.calendars_enable_all,
|
||||
),
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (allEnabled) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
open = false
|
||||
onToggleAll(!allEnabled)
|
||||
},
|
||||
)
|
||||
HorizontalDivider(Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(manageLabel) },
|
||||
leadingIcon = { Icon(manageIcon, contentDescription = null) },
|
||||
onClick = {
|
||||
open = false
|
||||
onManage()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */
|
||||
/**
|
||||
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
|
||||
* round 40dp chip, so each synced account is recognisable at a glance. We load
|
||||
* whatever app owns the account from [PackageManager] rather than bundling brand
|
||||
* logos — always accurate, nothing to license. Falls back to a neutral cloud
|
||||
* chip when no installed app resolves for the account.
|
||||
*/
|
||||
@Composable
|
||||
private fun SourceLogo(accountType: String) {
|
||||
val context = LocalContext.current
|
||||
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
|
||||
if (logo != null) {
|
||||
Image(
|
||||
bitmap = logo,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
LeadingAvatar(Icons.Default.Cloud)
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||
val pm = context.packageManager
|
||||
val candidates = buildList {
|
||||
curatedSourcePackage(accountType)?.let { add(it) }
|
||||
AccountManager.get(context).authenticatorTypes
|
||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||
?.packageName
|
||||
?.let { add(it) }
|
||||
}
|
||||
for (pkg in candidates) {
|
||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||
if (bitmap != null) return bitmap.asImageBitmap()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||
@Composable
|
||||
private fun LeadingAvatar(icon: ImageVector) {
|
||||
Box(
|
||||
@@ -500,25 +802,6 @@ private fun LeadingAvatar(icon: ImageVector) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
||||
@Composable
|
||||
private fun AddAvatar() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
@@ -539,6 +822,95 @@ private fun HintText(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(uriString) {
|
||||
uriString?.let {
|
||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||
@Composable
|
||||
private fun backupIntervalLabel(minutes: Long): String {
|
||||
val duration = when {
|
||||
minutes % MINUTES_PER_WEEK == 0L ->
|
||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||
minutes % MINUTES_PER_DAY == 0L ->
|
||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||
minutes % 60L == 0L ->
|
||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||
else ->
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||
}
|
||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||
}
|
||||
|
||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||
@Composable
|
||||
private fun backupStatusText(status: BackupStatus): String {
|
||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||
val relative = DateUtils.getRelativeTimeSpanString(
|
||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
return if (status.lastSuccess) {
|
||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||
} else {
|
||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||
}
|
||||
}
|
||||
|
||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||
@Composable
|
||||
private fun BackupIntervalDialog(
|
||||
currentMinutes: Long,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||
onDismiss()
|
||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440L
|
||||
private const val MINUTES_PER_WEEK = 10_080L
|
||||
|
||||
/**
|
||||
* Pick the app to open for managing a synced calendar's account. The account's
|
||||
* own authenticator package (resolved from [AccountManager], no permission
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -15,6 +22,8 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -31,8 +40,11 @@ import javax.inject.Inject
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarsViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -46,6 +58,36 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Calendars the user has disabled in the app. This screen is the only
|
||||
* surface that lists them, so it both reads the set (to dim the rows) and
|
||||
* toggles it. Every other surface simply excludes these ids.
|
||||
*/
|
||||
val disabledCalendarIds: StateFlow<Set<Long>> =
|
||||
prefs.disabledCalendarIds
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
settingsPrefs.autoBackupIntervalMinutes,
|
||||
settingsPrefs.autoBackupFolderUri,
|
||||
settingsPrefs.autoBackupStatus,
|
||||
) { enabled, interval, folder, status ->
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AutoBackupUiState(),
|
||||
)
|
||||
|
||||
private val _error = MutableStateFlow(false)
|
||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||
|
||||
@@ -93,6 +135,76 @@ class CalendarsViewModel @Inject constructor(
|
||||
repository.deleteCalendar(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a calendar app-side. Disabling removes it from every
|
||||
* surface but Settings → Calendars (and hides its events) without touching
|
||||
* provider data — purely a reversible Calendula-local view choice.
|
||||
*/
|
||||
fun setDisabled(id: Long, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + id else current - id
|
||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable every calendar of one account in a single write — the
|
||||
* "toggle all" affordance on an account header. Done as one set update so the
|
||||
* per-calendar [setDisabled] calls can't race each other.
|
||||
*/
|
||||
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + ids else current - ids.toSet()
|
||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
fun setAutoBackupEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsPrefs.setAutoBackupEnabled(enabled)
|
||||
reschedule()
|
||||
// Give immediate feedback when turning it on with a folder already set.
|
||||
if (enabled && settingsPrefs.autoBackupFolderUri.first() != null) {
|
||||
BackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||
viewModelScope.launch {
|
||||
settingsPrefs.setAutoBackupIntervalMinutes(minutes)
|
||||
reschedule()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the chosen destination folder (taking a durable write grant) and run once. */
|
||||
fun setAutoBackupFolder(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
settingsPrefs.setAutoBackupFolderUri(uri.toString())
|
||||
reschedule()
|
||||
if (settingsPrefs.autoBackupEnabled.first()) BackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reschedule() {
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
enabled = settingsPrefs.autoBackupEnabled.first(),
|
||||
intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = settingsPrefs.autoBackupFolderUri.first() != null,
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -106,6 +218,14 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Automatic-backup settings + last-run status for the Backup section. */
|
||||
data class AutoBackupUiState(
|
||||
val enabled: Boolean = false,
|
||||
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
|
||||
val folderUri: String? = null,
|
||||
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
|
||||
)
|
||||
|
||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||
sealed interface BackupResult {
|
||||
data class Success(val eventCount: Int) : BackupResult
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/**
|
||||
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||
*
|
||||
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||
* so it overlays the UI.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.DebugRibbon() {
|
||||
Text(
|
||||
text = "DEBUG",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.zIndex(1f)
|
||||
// Push the band out so its midline crosses the very corner, then
|
||||
// rotate it to the classic 45° ribbon.
|
||||
.offset(x = 36.dp, y = 24.dp)
|
||||
.rotate(45f)
|
||||
.background(Color(0xFFB23B00))
|
||||
.width(140.dp)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
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.Place
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
|
||||
/**
|
||||
* The shared label and icon for each optional [EventFormField]. Both the event
|
||||
* editor (which shows these fields) and the settings screen (which toggles their
|
||||
* defaults) draw from this one mapping, so a field reads the same wherever it
|
||||
* appears.
|
||||
*/
|
||||
@StringRes
|
||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
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
|
||||
}
|
||||
|
||||
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Location -> Icons.Default.Place
|
||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
EventFormField.Visibility -> Icons.Default.Lock
|
||||
EventFormField.Color -> Icons.Default.Palette
|
||||
EventFormField.Attendees -> Icons.Default.People
|
||||
}
|
||||
@@ -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),
|
||||
@@ -128,7 +127,9 @@ fun CollapsingScaffold(
|
||||
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
||||
* corner radii come from its [position] (so a run of rows reads as a single
|
||||
* rounded card). Corners round further on press. A null [onClick] makes the
|
||||
* row non-interactive (e.g. read-only entries).
|
||||
* row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
|
||||
* summary to the M3 disabled emphasis while leaving the [trailing] control at
|
||||
* full opacity — for rows that are present but switched off.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -138,6 +139,8 @@ fun GroupedRow(
|
||||
modifier: Modifier = Modifier,
|
||||
summary: String? = null,
|
||||
selected: Boolean = false,
|
||||
dimmed: Boolean = false,
|
||||
container: Color? = null,
|
||||
minHeight: Dp = 72.dp,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
@@ -169,6 +172,16 @@ fun GroupedRow(
|
||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else if (dimmed) {
|
||||
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
|
||||
// stays full-opacity so the toggle reads as live even on a faded row.
|
||||
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = muted,
|
||||
leadingIconColor = muted,
|
||||
supportingColor = muted,
|
||||
)
|
||||
} else {
|
||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
}
|
||||
@@ -186,10 +199,10 @@ fun GroupedRow(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.then(gap)
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val containerColor = when {
|
||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||
container != null -> container
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline draws an hour separator line, from the
|
||||
* `showHourLines` preference. Provided once at the app root (like
|
||||
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
|
||||
* Defaults to off — the historical clean look.
|
||||
*/
|
||||
val LocalShowHourLines = staticCompositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* Draw a faint separator line at the top of each hour (1..23) when [show] is
|
||||
* true. Applied to a day column's content so each line sits over the column's
|
||||
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
|
||||
* height; [color] is resolved by the caller from the theme.
|
||||
*/
|
||||
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
||||
if (!show) {
|
||||
this
|
||||
} else {
|
||||
drawBehind {
|
||||
for (hour in 1 until 24) {
|
||||
val y = hour * hourHeightPx
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(size.width, y),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ private val LineThickness = 2.dp
|
||||
* the middle of a minute. Drives the "now" line in the day/week grids.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberCurrentMinute(): State<Instant> {
|
||||
fun rememberCurrentMinute(): State<Instant> {
|
||||
val instant = remember { mutableStateOf(Clock.System.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The cut-off instant before which a calendar event counts as "completed" and is
|
||||
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
||||
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
||||
* dims). Provided per grid screen so only the event chips that read it recompose
|
||||
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
||||
*/
|
||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
@@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -25,11 +27,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -37,6 +41,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
|
||||
/**
|
||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||
@@ -83,8 +88,10 @@ fun <T> OptionPicker(
|
||||
label: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
) {
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
header?.invoke(this)
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
@@ -106,10 +113,15 @@ fun <T> OptionPicker(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
||||
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
||||
* a "Custom" row that expands an inline number field plus a segmented unit
|
||||
* selector. Returns the choice as a [CalendarReminderOverride].
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
|
||||
* default reminder" row above the list; choosing it defers to the global default
|
||||
* and clears the checked times. With nothing checked and inherit off, the choice
|
||||
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
|
||||
* expands an inline number field plus a unit selector to add an arbitrary lead
|
||||
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderDefaultPicker(
|
||||
@@ -120,68 +132,90 @@ fun ReminderDefaultPicker(
|
||||
onSelect: (CalendarReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
||||
// Optimistic local state: the chosen override is authoritative while the
|
||||
// picker is open, so quick successive toggles compose on each other instead
|
||||
// of racing the round-trip through the settings flow (which would drop a
|
||||
// toggle made before the previous write echoes back). Seeded once on open;
|
||||
// the picker leaves composition on dismiss, so reopening re-reads [selected].
|
||||
var current by remember { mutableStateOf(selected) }
|
||||
val inherits = current is CalendarReminderOverride.Inherit
|
||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
// Presets plus any selected custom (non-preset) values, each as its own row.
|
||||
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
|
||||
val options = buildList {
|
||||
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
||||
add(CalendarReminderOverride.None)
|
||||
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
||||
fun apply(override: CalendarReminderOverride) {
|
||||
current = override
|
||||
onSelect(override)
|
||||
}
|
||||
val rowCount = options.size + 1 // + the custom row
|
||||
// Emit the override for a new set of minutes: empty collapses to None, which
|
||||
// (with inherit unchosen) reads as an explicit "no reminder".
|
||||
fun emit(minutes: List<Int>) {
|
||||
val norm = minutes.distinct().sorted()
|
||||
apply(
|
||||
if (norm.isEmpty()) {
|
||||
CalendarReminderOverride.None
|
||||
} else {
|
||||
CalendarReminderOverride.Minutes(norm)
|
||||
},
|
||||
)
|
||||
}
|
||||
fun toggle(minute: Int) =
|
||||
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
// Exclusive "use default" choice (per-calendar only): its own group above
|
||||
// the multi-select list, since inheriting is mutually exclusive with
|
||||
// picking specific times.
|
||||
if (allowInherit) {
|
||||
GroupedRow(
|
||||
title = reminderOverrideLabel(option),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
title = stringResource(R.string.reminder_use_default),
|
||||
position = Position.Alone,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
val rowCount = rows.size + 1 // + the custom row
|
||||
rows.forEachIndexed { index, minute ->
|
||||
val checked = minute in selectedMinutes
|
||||
GroupedRow(
|
||||
title = reminderLeadTimeLabel(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = checked,
|
||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||
onClick = { toggle(minute) },
|
||||
)
|
||||
}
|
||||
// When expanded, the Custom row connects downward into the editor card
|
||||
// so the two read as one grouped container (the per-calendar pattern).
|
||||
GroupedRow(
|
||||
title = if (customSelected) {
|
||||
stringResource(
|
||||
R.string.reminder_custom_with_value,
|
||||
reminderLeadTimeLabel(selectedMinutes!!),
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.event_edit_reminder_custom)
|
||||
},
|
||||
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
title = stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(visible = customExpanded) {
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
CustomReminderEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
onConfirm = { minutes ->
|
||||
onSelect(CalendarReminderOverride.Minutes(minutes))
|
||||
onDismiss()
|
||||
emit(selectedMinutes + minutes)
|
||||
amountText = ""
|
||||
customExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -256,6 +290,17 @@ private fun CustomReminderEditor(
|
||||
}
|
||||
}
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
@Composable
|
||||
private fun PickerDescription(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedCheck() {
|
||||
Icon(
|
||||
@@ -265,18 +310,146 @@ private fun SelectedCheck() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||
*/
|
||||
@Composable
|
||||
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||
fun AgendaRangePicker(
|
||||
title: String,
|
||||
description: String,
|
||||
selected: AgendaRange,
|
||||
onSelect: (AgendaRange) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth)
|
||||
val rolling = listOf(AgendaRange.Week, AgendaRange.Month)
|
||||
val rollingRowCount = rolling.size + 1 // + the custom row
|
||||
val customSelected = selected is AgendaRange.Custom
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable {
|
||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||
}
|
||||
|
||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = agendaRangeLabel(option),
|
||||
position = position,
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
PickerDescription(description)
|
||||
|
||||
// Calendar-aligned windows.
|
||||
calendarAligned.forEachIndexed { index, option ->
|
||||
rangeRow(option, positionOf(index, calendarAligned.size))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Rolling windows + custom.
|
||||
rolling.forEachIndexed { index, option ->
|
||||
rangeRow(option, positionOf(index, rollingRowCount))
|
||||
}
|
||||
GroupedRow(
|
||||
title = if (customSelected) {
|
||||
agendaRangeLabel(selected)
|
||||
} else {
|
||||
stringResource(R.string.agenda_range_custom)
|
||||
},
|
||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
CustomDaysEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
onConfirm = { days ->
|
||||
onSelect(AgendaRange.Custom(days))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
||||
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
||||
minutes == null -> "" to ReminderUnit.Minutes
|
||||
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
||||
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
||||
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
||||
else -> minutes.toString() to ReminderUnit.Minutes
|
||||
/** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */
|
||||
@Composable
|
||||
private fun CustomDaysEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val days = amountText.toIntOrNull()
|
||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = onAmountChange,
|
||||
placeholder = "30",
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||
?: stringResource(R.string.agenda_range_custom_hint),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { days?.let(onConfirm) },
|
||||
enabled = days != null,
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||
@Composable
|
||||
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||
AgendaRange.Day -> stringResource(R.string.agenda_range_day)
|
||||
AgendaRange.ThisWeek -> stringResource(R.string.agenda_range_this_week)
|
||||
AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month)
|
||||
AgendaRange.Week -> stringResource(R.string.agenda_range_week)
|
||||
AgendaRange.Month -> stringResource(R.string.agenda_range_month)
|
||||
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
|
||||
* `false` = 12-hour ("2:00 PM"). Provided once at the app root from the
|
||||
* `TimeFormatPref` preference resolved against the device's 24-hour system
|
||||
* setting, so every time label reads it without per-screen plumbing. Defaults
|
||||
* to 24-hour for previews and any composition that forgets to provide it.
|
||||
*/
|
||||
val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
||||
|
||||
private const val PATTERN_24 = "HH:mm"
|
||||
private const val PATTERN_12 = "h:mm a"
|
||||
private const val HOUR_PATTERN_12 = "h a"
|
||||
|
||||
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern(if (is24Hour) PATTERN_24 else PATTERN_12, locale)
|
||||
|
||||
/**
|
||||
* Format a wall-clock time (hour 0..23, minute 0..59) honouring the resolved
|
||||
* [is24Hour] convention and [locale]: 24h → "14:00", 12h → "2:00 PM". Pure, so
|
||||
* it can be unit-tested and used off the main thread (widget, notifications).
|
||||
*/
|
||||
fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
|
||||
.format(timeOfDayFormatter(is24Hour, locale))
|
||||
|
||||
/**
|
||||
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
|
||||
* 1440 renders as "24:00" in 24h (its established reading) and as midnight
|
||||
* ("12:00 AM") in 12h, which has no 24:00 equivalent.
|
||||
*/
|
||||
fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String = when {
|
||||
minutes >= MINUTES_PER_DAY && is24Hour -> "24:00"
|
||||
minutes >= MINUTES_PER_DAY -> formatTimeOfDay(0, 0, is24Hour = false, locale)
|
||||
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||
* the prior look); 12h → "1 PM".
|
||||
*/
|
||||
fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
if (is24Hour) {
|
||||
"%02d".format(hour)
|
||||
} else {
|
||||
LocalTime.of(hour.coerceIn(0, 23), 0)
|
||||
.format(DateTimeFormatter.ofPattern(HOUR_PATTERN_12, locale))
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
@@ -75,10 +75,17 @@ 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
|
||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -237,6 +244,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
|
||||
@@ -287,7 +296,7 @@ private fun DayContent(
|
||||
DayUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
@@ -462,6 +471,8 @@ private fun Timeline(
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||
@@ -484,7 +495,7 @@ private fun Timeline(
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = "%02d".format(h),
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
@@ -530,6 +541,8 @@ private fun DayColumnCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
Card(
|
||||
// Plain rectangular column — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -542,6 +555,9 @@ private fun DayColumnCard(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Faint hour separators sit over the column background but under
|
||||
// the event blocks (drawBehind paints before the children).
|
||||
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||
// Tap an empty slot to create an event there. Taps on event
|
||||
// blocks are consumed by their own click handler first, so this
|
||||
// only fires on the column background. Snaps to the tapped hour.
|
||||
@@ -585,7 +601,10 @@ private fun EventBlock(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -631,8 +650,8 @@ private fun DayLoading() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun minToHm(min: Int): String =
|
||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatDayTitle(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
@@ -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,9 +91,12 @@ 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
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
@@ -132,8 +134,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 +214,7 @@ fun EventDetailScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = onBack),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -782,7 +783,7 @@ private fun formatWhen(
|
||||
val zid = ZoneId.of(zone.id)
|
||||
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
|
||||
|
||||
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||
|
||||
@@ -8,14 +8,9 @@ import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.util.Patterns
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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
|
||||
@@ -48,7 +43,6 @@ 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
|
||||
@@ -63,7 +57,6 @@ import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -88,6 +81,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
@@ -121,11 +116,16 @@ 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
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
@@ -135,6 +135,8 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
@@ -202,8 +204,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) {
|
||||
@@ -253,6 +253,7 @@ fun EventEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.predictiveBack(onBack = close),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -410,7 +411,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,
|
||||
@@ -418,10 +418,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)
|
||||
}
|
||||
@@ -483,6 +481,17 @@ private fun EventEditContent(
|
||||
?: MaterialTheme.colorScheme.primary
|
||||
val gap = 12.dp
|
||||
|
||||
// Issue #10: on a fresh create form (setting on, nothing typed yet) focus the
|
||||
// title and raise the keyboard so the user can type straight away. Keyed on
|
||||
// Unit so it runs once per open — not on every keystroke — and guarded so
|
||||
// editing or an imported/prefilled form never grabs focus.
|
||||
val titleFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) {
|
||||
runCatching { titleFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
// Per-event colour applicability for the resolved calendar:
|
||||
// - palette calendars (Google, …) and local calendars always support it;
|
||||
// - synced calendars with no palette only when the user opted in, and even
|
||||
@@ -510,6 +519,10 @@ private fun EventEditContent(
|
||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||
textStyle = MaterialTheme.typography.headlineMedium
|
||||
.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(titleFocusRequester),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
@@ -1024,9 +1037,9 @@ private fun FieldPickerDialog(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
hiddenFields.forEach { field ->
|
||||
OptionCard(
|
||||
label = stringResource(fieldLabel(field)),
|
||||
label = stringResource(eventFormFieldLabel(field)),
|
||||
onClick = { onSelect(field) },
|
||||
icon = fieldIcon(field),
|
||||
icon = eventFormFieldIcon(field),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1643,28 +1656,6 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||
}
|
||||
|
||||
|
||||
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
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) {
|
||||
EventFormField.Location -> Icons.Default.Place
|
||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
EventFormField.Visibility -> Icons.Default.Lock
|
||||
EventFormField.Color -> Icons.Default.Palette
|
||||
EventFormField.Attendees -> Icons.Default.People
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility selector: one card per level, each with its own icon; the
|
||||
* current level is highlighted. Tap picks and closes.
|
||||
@@ -1898,8 +1889,9 @@ private fun ScheduleRow(
|
||||
val dateFormat = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
}
|
||||
val timeFormat = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeFormat = remember(locale, use24Hour) {
|
||||
timeOfDayFormatter(use24Hour, locale)
|
||||
}
|
||||
// Tappable values read as links (primary), like the location on the
|
||||
// detail screen; errors flip them to the error colour.
|
||||
|
||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
val isEditing: Boolean = false,
|
||||
/**
|
||||
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||
* on it for a new event with an empty title, never when editing/importing.
|
||||
*/
|
||||
val autofocusTitle: Boolean = true,
|
||||
/**
|
||||
* True while an edit changed the recurrence rule — the save-scope dialog
|
||||
* then drops "only this event" (an exception row can't carry a rule).
|
||||
|
||||
@@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val timed: Int?,
|
||||
val allDay: Int?,
|
||||
val timedOverrides: Map<Long, Int?>,
|
||||
val allDayOverrides: Map<Long, Int?>,
|
||||
val timed: List<Int>,
|
||||
val allDay: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
)
|
||||
|
||||
private data class ExternalInputs(
|
||||
@@ -119,12 +119,21 @@ class EventEditViewModel @Inject constructor(
|
||||
val lastUsed: Long?,
|
||||
val defaultFields: Set<EventFormField>,
|
||||
val allowColorOnUnsupported: Boolean,
|
||||
val autofocusTitle: Boolean,
|
||||
)
|
||||
|
||||
/** Writable calendars — the only valid event targets. */
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||
.catch { emit(emptyList()) }
|
||||
/**
|
||||
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||
* excluded, so you can't create into a calendar you've removed from the app;
|
||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||
*/
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||
repository.calendars(),
|
||||
prefs.disabledCalendarIds,
|
||||
) { calendars, disabled ->
|
||||
calendars.filter { it.canModifyContents && it.id !in disabled }
|
||||
}.catch { emit(emptyList()) }
|
||||
|
||||
/** The target calendar id, resolved exactly as the form shows it. */
|
||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||
@@ -152,6 +161,7 @@ class EventEditViewModel @Inject constructor(
|
||||
prefs.lastUsedCalendarId,
|
||||
settingsPrefs.defaultFormFields,
|
||||
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||
settingsPrefs.autofocusEventTitle,
|
||||
::ExternalInputs,
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
@@ -170,6 +180,7 @@ class EventEditViewModel @Inject constructor(
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
isEditing = local.editTarget != null,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
// A modified-occurrence exception can't carry its own rule, so
|
||||
// the scope dialog drops "only this event" after a rule change.
|
||||
recurrenceChanged = local.editTarget != null &&
|
||||
@@ -249,7 +260,7 @@ class EventEditViewModel @Inject constructor(
|
||||
// Re-check after suspending: bail if the form closed or the user edited.
|
||||
val form = _form.value ?: return@launch
|
||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||
val default = resolveDefaultReminder(
|
||||
val reminders = resolveDefaultReminder(
|
||||
timedGlobal = defaults.timed,
|
||||
allDayGlobal = defaults.allDay,
|
||||
timedOverrides = defaults.timedOverrides,
|
||||
@@ -257,7 +268,6 @@ class EventEditViewModel @Inject constructor(
|
||||
calendarId = targetId,
|
||||
isAllDay = form.isAllDay,
|
||||
)
|
||||
val reminders = listOfNotNull(default)
|
||||
_form.value = form.copy(reminders = reminders)
|
||||
// Surface the section so an auto-applied default is visible and
|
||||
// removable, even when Reminders isn't a default-shown field.
|
||||
|
||||
@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
|
||||
combine(
|
||||
repository.calendars(),
|
||||
prefs.hiddenCalendarIds,
|
||||
) { calendars, hidden ->
|
||||
if (calendars.isEmpty()) {
|
||||
prefs.disabledCalendarIds,
|
||||
) { calendars, hidden, disabled ->
|
||||
// Disabled calendars are gone from the app entirely — they don't
|
||||
// belong in the drawer's hide/show list (you can't hide what's
|
||||
// already disabled). They live only in Settings → Calendars.
|
||||
val enabled = calendars.filterNot { it.id in disabled }
|
||||
if (enabled.isEmpty()) {
|
||||
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
} else {
|
||||
FilterUiState.Success(groupByAccount(calendars, hidden))
|
||||
FilterUiState.Success(groupByAccount(enabled, hidden))
|
||||
}
|
||||
}
|
||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
|
||||
@@ -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)) },
|
||||
|
||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
|
||||
class ImportViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val importer: IcsImporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -79,11 +81,17 @@ class ImportViewModel @Inject constructor(
|
||||
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||
warnings = parsed.warnings,
|
||||
)
|
||||
else -> ImportUiState.Many(
|
||||
events = parsed.events,
|
||||
warnings = parsed.warnings,
|
||||
calendars = repository.calendars().first().filter { it.canModifyContents },
|
||||
)
|
||||
else -> {
|
||||
// A disabled calendar is removed from the app, so it can't be
|
||||
// an import target — exclude it alongside the read-only ones.
|
||||
val disabled = prefs.disabledCalendarIds.first()
|
||||
ImportUiState.Many(
|
||||
events = parsed.events,
|
||||
warnings = parsed.warnings,
|
||||
calendars = repository.calendars().first()
|
||||
.filter { it.canModifyContents && it.id !in disabled },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -44,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -60,12 +63,18 @@ 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.hasEnded
|
||||
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.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
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
|
||||
@@ -95,6 +104,14 @@ fun MonthScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
val nowState = rememberCurrentMinute()
|
||||
val dimCutoff by remember(dimCompleted) {
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -191,14 +208,16 @@ fun MonthScreen(
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
WeekdayHeader(weekStart = weekStart)
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,6 +236,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(
|
||||
@@ -243,7 +264,7 @@ private fun MonthContent(
|
||||
MonthUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "month-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
@@ -554,6 +575,8 @@ private fun MonthBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
@@ -561,7 +584,7 @@ private fun MonthBar(
|
||||
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), shape)
|
||||
.padding(horizontal = 4.dp)
|
||||
.semantics { contentDescription = title },
|
||||
|
||||
@@ -59,6 +59,14 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.jeanlucmakiola.calendula.ui.search
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -49,10 +48,14 @@ 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.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import java.time.Instant as JavaInstant
|
||||
@@ -88,10 +91,8 @@ fun SearchScreen(
|
||||
focusRequester.requestFocus()
|
||||
keyboard?.show()
|
||||
}
|
||||
BackHandler(onBack = onBack)
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
modifier = modifier.predictiveBack(onBack = onBack),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -174,6 +175,7 @@ private fun SearchResults(
|
||||
SearchResultRow(
|
||||
event = event,
|
||||
position = positionOf(index, events.size),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
@@ -184,10 +186,12 @@ private fun SearchResults(
|
||||
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,
|
||||
@@ -215,12 +219,11 @@ private fun searchSummary(event: EventInstance): String {
|
||||
val dateText = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
}.format(start)
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeText = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
}.format(start)
|
||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||
}
|
||||
val base = "$dateText · $timeText"
|
||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||
|
||||
@@ -39,14 +39,19 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Gavel
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -81,15 +86,23 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
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.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
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
|
||||
@@ -99,7 +112,12 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalTime
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Calendar
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
@@ -208,6 +226,12 @@ private fun SettingsHub(
|
||||
onClick = onManageCalendars,
|
||||
)
|
||||
LanguageRow(position = Position.Middle)
|
||||
// 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 here.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
QuickSettingsTileRow(position = Position.Middle)
|
||||
}
|
||||
ReportProblemRow(position = Position.Bottom)
|
||||
|
||||
AppVersionText()
|
||||
@@ -249,6 +273,24 @@ private fun ReportProblemRow(position: Position) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the system to add the "New event" Quick Settings tile (API 33+). A
|
||||
* top-level action row, like [ReportProblemRow] — the tile is a system-surface
|
||||
* shortcut, not part of any one settings category.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
@Composable
|
||||
private fun QuickSettingsTileRow(position: Position) {
|
||||
val context = LocalContext.current
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_qs_tile),
|
||||
summary = stringResource(R.string.settings_qs_tile_hint),
|
||||
position = position,
|
||||
leading = { CategoryIcon(Icons.Default.Dashboard, ChipAccent.Neutral) },
|
||||
onClick = { requestAddQsTile(context) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageRow(position: Position) {
|
||||
val context = LocalContext.current
|
||||
@@ -279,6 +321,18 @@ private fun LanguageRow(position: Position) {
|
||||
AppLanguage.apply(it)
|
||||
},
|
||||
onDismiss = { showDialog = false },
|
||||
// Invite contributions right where users pick their language.
|
||||
header = {
|
||||
val translateUrl = stringResource(R.string.about_translate_url)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_translate),
|
||||
summary = stringResource(R.string.settings_translate_hint),
|
||||
position = Position.Alone,
|
||||
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
|
||||
onClick = { openUrl(context, translateUrl) },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -288,6 +342,7 @@ private fun AboutCard() {
|
||||
val context = LocalContext.current
|
||||
val sourceUrl = stringResource(R.string.about_source_url)
|
||||
val licenseUrl = stringResource(R.string.about_license_url)
|
||||
val supportUrl = stringResource(R.string.about_support_url)
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
@@ -346,6 +401,19 @@ private fun AboutCard() {
|
||||
Text(stringResource(R.string.settings_license))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { openUrl(context, supportUrl) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.settings_about_support))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,11 +472,17 @@ private fun AppearanceScreen(
|
||||
) {
|
||||
var showTheme by remember { mutableStateOf(false) }
|
||||
var showWeekStart by remember { mutableStateOf(false) }
|
||||
var showTimeFormat by remember { mutableStateOf(false) }
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
onBack = onBack,
|
||||
) {
|
||||
// Theme & colour
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
summary = themeLabel(state.themeMode),
|
||||
@@ -422,7 +496,7 @@ private fun AppearanceScreen(
|
||||
} else {
|
||||
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||
},
|
||||
position = Position.Middle,
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dynamicColor,
|
||||
@@ -436,12 +510,88 @@ private fun AppearanceScreen(
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Calendar — view, week, and timeline formatting
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
summary = stringResource(state.defaultView.labelRes),
|
||||
position = Position.Top,
|
||||
onClick = { showDefaultView = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
summary = weekStartLabel(state.weekStart),
|
||||
position = Position.Bottom,
|
||||
position = Position.Middle,
|
||||
onClick = { showWeekStart = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_time_format),
|
||||
summary = timeFormatLabel(state.timeFormat),
|
||||
position = Position.Middle,
|
||||
onClick = { showTimeFormat = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_hour_lines),
|
||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showHourLines,
|
||||
onCheckedChange = viewModel::setShowHourLines,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dim_completed),
|
||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dimCompletedEvents,
|
||||
onCheckedChange = viewModel::setDimCompletedEvents,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Agenda — the only group labelled, so its agenda-specific rows are easy
|
||||
// to pick out among the otherwise unheadered Appearance settings.
|
||||
SectionHeader(stringResource(R.string.settings_agenda_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||
position = Position.Top,
|
||||
onClick = { showAgendaScreenRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
summary = pastEventDisplayLabel(state.pastEventDisplay),
|
||||
position = Position.Middle,
|
||||
onClick = { showPastEvents = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||
position = Position.Middle,
|
||||
onClick = { showAgendaWidgetRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range_bar),
|
||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.agendaShowRangeBar,
|
||||
onCheckedChange = viewModel::setAgendaShowRangeBar,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
||||
)
|
||||
}
|
||||
|
||||
if (showTheme) {
|
||||
@@ -457,13 +607,61 @@ private fun AppearanceScreen(
|
||||
if (showWeekStart) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
options = WeekStartPref.entries,
|
||||
options = WEEK_START_OPTIONS,
|
||||
selected = state.weekStart,
|
||||
label = { weekStartLabel(it) },
|
||||
onSelect = viewModel::setWeekStart,
|
||||
onDismiss = { showWeekStart = false },
|
||||
)
|
||||
}
|
||||
if (showAgendaScreenRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.settings_agenda_range_hint),
|
||||
selected = state.agendaScreenRange,
|
||||
onSelect = viewModel::setAgendaScreenRange,
|
||||
onDismiss = { showAgendaScreenRange = false },
|
||||
)
|
||||
}
|
||||
if (showAgendaWidgetRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||
selected = state.agendaWidgetRange,
|
||||
onSelect = viewModel::setAgendaWidgetRange,
|
||||
onDismiss = { showAgendaWidgetRange = false },
|
||||
)
|
||||
}
|
||||
if (showTimeFormat) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_time_format),
|
||||
options = TimeFormatPref.entries,
|
||||
selected = state.timeFormat,
|
||||
label = { timeFormatLabel(it) },
|
||||
onSelect = viewModel::setTimeFormat,
|
||||
onDismiss = { showTimeFormat = false },
|
||||
)
|
||||
}
|
||||
if (showPastEvents) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_past_events),
|
||||
options = PastEventDisplay.entries,
|
||||
selected = state.pastEventDisplay,
|
||||
label = { pastEventDisplayLabel(it) },
|
||||
onSelect = viewModel::setPastEventDisplay,
|
||||
onDismiss = { showPastEvents = 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
|
||||
@@ -487,8 +685,17 @@ private fun EventFormScreen(
|
||||
fields.forEachIndexed { index, field ->
|
||||
val checked = field in state.defaultFormFields
|
||||
GroupedRow(
|
||||
title = stringResource(formFieldLabel(field)),
|
||||
title = stringResource(eventFormFieldLabel(field)),
|
||||
position = positionOf(index, fields.size),
|
||||
// Same icon the field carries in the new-event form, so a toggle
|
||||
// is easy to match to the field it controls.
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = eventFormFieldIcon(field),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
@@ -499,6 +706,30 @@ private fun EventFormScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-focus the title on a new event (issue #10) — on by default, since
|
||||
// most events get a title; raising the keyboard saves a tap. Off lets you
|
||||
// set the time/calendar first without the keyboard in the way.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_autofocus_title),
|
||||
summary = stringResource(R.string.settings_autofocus_title_hint),
|
||||
position = Position.Alone,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Keyboard,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.autofocusEventTitle,
|
||||
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||
)
|
||||
|
||||
// Per-event colour on calendars that publish no colour set (some
|
||||
// CalDAV) — off by default, with the honest caveat that the colour may
|
||||
// not survive their next sync. Local and palette calendars ignore it.
|
||||
@@ -519,20 +750,6 @@ 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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,6 +801,7 @@ private fun NotificationsScreen(
|
||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||
var showSnooze by remember { mutableStateOf(false) }
|
||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||
|
||||
CollapsingScaffold(
|
||||
@@ -621,65 +839,11 @@ private fun NotificationsScreen(
|
||||
onClick = { showAllDayReminderTime = true },
|
||||
)
|
||||
|
||||
// Per-calendar overrides: each writable calendar may keep, drop, or
|
||||
// replace the global default — separately for timed and all-day events.
|
||||
if (state.writableCalendars.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_calendar_reminders_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
// Calendar card; tapping expands it into a grouped list of three
|
||||
// (the card + the timed and all-day override rows).
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
expandedCalendars = if (expanded) {
|
||||
expandedCalendars - calendar.id
|
||||
} else {
|
||||
expandedCalendars + calendar.id
|
||||
}
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Column {
|
||||
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery reliability: Android's battery optimisation can delay or drop
|
||||
// the calendar provider's reminder broadcast. A soft, optional exemption
|
||||
// (system-settings deep-link, no special permission) improves on-time
|
||||
// delivery; shown as live status, reversible by the user at any time.
|
||||
// Delivery reliability + snooze: both are global reminder-delivery
|
||||
// settings, so they sit with the defaults above rather than below the
|
||||
// long per-calendar list. Reliability is a soft, optional battery-
|
||||
// optimisation exemption (system-settings deep-link, no special
|
||||
// permission); shown as live status, reversible at any time.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
val batteryExempt = rememberBatteryOptimizationExempt()
|
||||
GroupedRow(
|
||||
@@ -689,7 +853,7 @@ private fun NotificationsScreen(
|
||||
} else {
|
||||
stringResource(R.string.settings_reliable_delivery_hint)
|
||||
},
|
||||
position = Position.Alone,
|
||||
position = Position.Top,
|
||||
trailing = if (batteryExempt) {
|
||||
{
|
||||
Icon(
|
||||
@@ -705,13 +869,88 @@ private fun NotificationsScreen(
|
||||
)
|
||||
|
||||
// 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,
|
||||
position = Position.Bottom,
|
||||
onClick = { showSnooze = true },
|
||||
)
|
||||
|
||||
// Per-calendar overrides: the whole section folds behind one header to
|
||||
// keep the screen tidy. Expanded, each writable calendar gets its own
|
||||
// expandable card that may keep, drop, or replace the global default —
|
||||
// separately for timed and all-day events.
|
||||
if (state.writableCalendars.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||
summary = stringResource(R.string.settings_calendar_reminders_hint),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = calendarSectionExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
Column {
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
Spacer(Modifier.height(16.dp))
|
||||
val expanded = calendar.id in expandedCalendars
|
||||
// Calendar card; tapping expands it into a grouped list
|
||||
// of three (the card + the timed and all-day rows).
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (expanded) Position.Top else Position.Alone,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
expandedCalendars = if (expanded) {
|
||||
expandedCalendars - calendar.id
|
||||
} else {
|
||||
expandedCalendars + calendar.id
|
||||
}
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
Column {
|
||||
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder),
|
||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||
position = Position.Middle,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||
)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSnooze) {
|
||||
@@ -730,7 +969,7 @@ private fun NotificationsScreen(
|
||||
presets = REMINDER_PRESETS,
|
||||
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
|
||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
|
||||
onDismiss = { showDefaultReminder = false },
|
||||
)
|
||||
}
|
||||
@@ -740,7 +979,7 @@ private fun NotificationsScreen(
|
||||
presets = ALLDAY_REMINDER_PRESETS,
|
||||
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
|
||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
||||
onDismiss = { showAllDayReminder = false },
|
||||
)
|
||||
}
|
||||
@@ -789,13 +1028,13 @@ private fun NotificationsScreen(
|
||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
/** A global default (null = none) as a picker choice for selection highlighting. */
|
||||
private fun Int?.toReminderChoice(): CalendarReminderOverride =
|
||||
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||
private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
|
||||
if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||
|
||||
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||
private fun CalendarReminderOverride.toMinutesOrNull(): Int? =
|
||||
(this as? CalendarReminderOverride.Minutes)?.minutes
|
||||
private fun CalendarReminderOverride.toMinutesList(): List<Int> =
|
||||
(this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||
|
||||
/**
|
||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||
@@ -870,27 +1109,33 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||
}
|
||||
|
||||
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
||||
private fun Map<Long, Int?>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||
this[calendarId] == null -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId)!!)
|
||||
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
|
||||
}
|
||||
|
||||
/** Label for a global-default choice: null → "None", else the lead time. */
|
||||
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
||||
@Composable
|
||||
private fun reminderChoiceLabel(minutes: Int?): String =
|
||||
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
|
||||
private fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
|
||||
// than a (non-composable) joinToString transform.
|
||||
val parts = ArrayList<String>(minutes.size)
|
||||
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
|
||||
return parts.joinToString(", ")
|
||||
}
|
||||
|
||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||
@Composable
|
||||
private fun calendarOverrideSummary(
|
||||
choice: CalendarReminderOverride,
|
||||
globalDefault: Int?,
|
||||
globalDefault: List<Int>,
|
||||
): String = when (choice) {
|
||||
CalendarReminderOverride.Inherit ->
|
||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||
is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -932,17 +1177,6 @@ private fun openUrl(context: Context, url: String) {
|
||||
runCatching { context.startActivity(intent) }
|
||||
}
|
||||
|
||||
private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
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
|
||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||
when (mode) {
|
||||
@@ -952,12 +1186,45 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||
},
|
||||
)
|
||||
|
||||
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
|
||||
private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
||||
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
||||
|
||||
@Composable
|
||||
private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
|
||||
private fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
|
||||
// Localised full weekday name, so any of the seven days reads naturally
|
||||
// without a per-day string resource.
|
||||
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
|
||||
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||
when (pref) {
|
||||
WeekStartPref.AUTO -> R.string.settings_week_start_auto
|
||||
WeekStartPref.MONDAY -> R.string.settings_week_start_monday
|
||||
WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday
|
||||
TimeFormatPref.AUTO -> R.string.settings_time_format_auto
|
||||
TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
|
||||
TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
|
||||
},
|
||||
)
|
||||
|
||||
/** A small primary-coloured group label, matching the Calendars settings screen. */
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResource(
|
||||
when (mode) {
|
||||
PastEventDisplay.SHOW -> R.string.settings_past_events_show
|
||||
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
@@ -16,30 +20,48 @@ data class SettingsUiState(
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val dynamicColor: Boolean = true,
|
||||
val dynamicColorAvailable: Boolean = true,
|
||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||
val showHourLines: Boolean = false,
|
||||
/** How the Agenda screen treats events that already ended today. */
|
||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
/** Whether the month/week grids fade events that have already finished. */
|
||||
val dimCompletedEvents: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** 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 the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||
val autofocusEventTitle: Boolean = true,
|
||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||
val remindersEnabled: Boolean = true,
|
||||
/**
|
||||
* The default reminder lead time (minutes) prefilled on new timed events;
|
||||
* null = no default reminder. Per-calendar overrides take precedence.
|
||||
* The default reminder lead times (minutes) prefilled on new timed events;
|
||||
* empty = no default reminder. Per-calendar overrides take precedence.
|
||||
*/
|
||||
val defaultReminderMinutes: Int? = null,
|
||||
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
||||
val defaultAllDayReminderMinutes: Int? = null,
|
||||
val defaultReminderMinutes: List<Int> = emptyList(),
|
||||
/** The default reminder lead times prefilled on new all-day events; empty = none. */
|
||||
val defaultAllDayReminderMinutes: List<Int> = emptyList(),
|
||||
/** 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
|
||||
* calendar present in the map overrides the global default (empty value = no
|
||||
* reminder); absent = inherit the global default.
|
||||
*/
|
||||
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||
/**
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -19,12 +33,15 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
repository: CalendarRepository,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
@@ -72,8 +89,33 @@ class SettingsViewModel @Inject constructor(
|
||||
) { overrides, allDayOverrides, calendars ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||
},
|
||||
) { base, defaults, overrides ->
|
||||
combine(
|
||||
prefs.defaultView,
|
||||
prefs.agendaScreenRange,
|
||||
prefs.agendaWidgetRange,
|
||||
prefs.timeFormat,
|
||||
prefs.showHourLines,
|
||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
||||
},
|
||||
combine(
|
||||
prefs.agendaShowRangeBar,
|
||||
prefs.autofocusEventTitle,
|
||||
prefs.pastEventDisplay,
|
||||
prefs.dimCompletedEvents,
|
||||
::MiscSettings,
|
||||
),
|
||||
) { base, defaults, overrides, views, misc ->
|
||||
base.copy(
|
||||
defaultView = views.defaultView,
|
||||
agendaScreenRange = views.agendaScreenRange,
|
||||
agendaWidgetRange = views.agendaWidgetRange,
|
||||
timeFormat = views.timeFormat,
|
||||
showHourLines = views.showHourLines,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
dimCompletedEvents = misc.dimCompletedEvents,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -89,20 +131,41 @@ class SettingsViewModel @Inject constructor(
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
)
|
||||
|
||||
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
|
||||
// updateAll calls at once — concurrent calls coalesce around a stale read
|
||||
// and can leave the widget on the intermediate value. Held sequentially,
|
||||
// the last call reads the final committed pref and renders it.
|
||||
private val widgetRefreshMutex = Mutex()
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val allowColor: Boolean,
|
||||
val defaultReminder: Int?,
|
||||
val allDayReminder: Int?,
|
||||
val defaultReminder: List<Int>,
|
||||
val allDayReminder: List<Int>,
|
||||
val allDayReminderTime: Int,
|
||||
val snoozeMinutes: Int,
|
||||
)
|
||||
|
||||
private data class ReminderOverrides(
|
||||
val timed: Map<Long, Int?>,
|
||||
val allDay: Map<Long, Int?>,
|
||||
val timed: Map<Long, List<Int>>,
|
||||
val allDay: Map<Long, List<Int>>,
|
||||
val calendars: List<CalendarSource>,
|
||||
)
|
||||
|
||||
private data class ViewSettings(
|
||||
val defaultView: CalendarView,
|
||||
val agendaScreenRange: AgendaRange,
|
||||
val agendaWidgetRange: AgendaRange,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val showHourLines: Boolean,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
val showRangeBar: Boolean,
|
||||
val autofocusEventTitle: Boolean,
|
||||
val pastEventDisplay: PastEventDisplay,
|
||||
val dimCompletedEvents: Boolean,
|
||||
)
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
@@ -112,7 +175,72 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun setWeekStart(pref: WeekStartPref) {
|
||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||
viewModelScope.launch {
|
||||
prefs.setWeekStart(pref)
|
||||
// Both widgets depend on the week start (month weekday header; the
|
||||
// agenda widget's "this week" range).
|
||||
widgetRefreshMutex.withLock {
|
||||
AgendaWidget().updateAll(appContext)
|
||||
MonthWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaScreenRange(range: AgendaRange) {
|
||||
viewModelScope.launch { prefs.setAgendaScreenRange(range) }
|
||||
}
|
||||
|
||||
fun setAgendaWidgetRange(range: AgendaRange) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaWidgetRange(range)
|
||||
widgetRefreshMutex.withLock {
|
||||
// Push the range into each instance's Glance state and recompose.
|
||||
// The widget reads it via currentState, so this reflects reliably
|
||||
// even when updateAll only recomposes a live session (it does not
|
||||
// re-run provideGlance's data preamble).
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaShowRangeBar(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||
}
|
||||
|
||||
fun setTimeFormat(pref: TimeFormatPref) {
|
||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||
}
|
||||
|
||||
fun setShowHourLines(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||
}
|
||||
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
viewModelScope.launch {
|
||||
prefs.setPastEventDisplay(mode)
|
||||
// The agenda widget honours this setting too, so push it into each
|
||||
// instance's Glance state and recompose — same reliable-update path as
|
||||
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDimCompletedEvents(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultView(view: CalendarView) {
|
||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||
}
|
||||
|
||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||
@@ -123,11 +251,15 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||
fun setAutofocusEventTitle(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -55,6 +57,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -73,14 +76,25 @@ 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.domain.hasEnded
|
||||
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.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
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.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
@@ -122,6 +136,14 @@ fun WeekScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
val nowState = rememberCurrentMinute()
|
||||
val dimCutoff by remember(dimCompleted) {
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -214,19 +236,21 @@ fun WeekScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,6 +271,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
|
||||
@@ -300,7 +326,7 @@ private fun WeekContent(
|
||||
WeekUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
@@ -541,8 +567,10 @@ private fun AllDayBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
@@ -568,6 +596,8 @@ private fun Timeline(
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||
@@ -591,7 +621,7 @@ private fun Timeline(
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = "%02d".format(h),
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
@@ -646,6 +676,8 @@ private fun DayColumnCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -658,6 +690,9 @@ private fun DayColumnCard(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Faint hour separators sit over the column background but under
|
||||
// the event blocks (drawBehind paints before the children).
|
||||
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||
// Tap an empty slot to create an event there; taps on event
|
||||
// blocks are consumed by their own handler first. Snaps to hour.
|
||||
.pointerInput(date) {
|
||||
@@ -676,6 +711,7 @@ private fun DayColumnCard(
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
@@ -696,14 +732,35 @@ private fun DayColumnCard(
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
||||
// On narrow overlapping columns we drop it so the title can wrap to fill the
|
||||
// whole block, mirroring Google Calendar.
|
||||
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
||||
// top/bottom padding and the reserved time line) instead of clipping it to a
|
||||
// single character on slim, overlapping blocks.
|
||||
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
@@ -713,18 +770,15 @@ private fun EventBlock(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
||||
// wrap to a second line (after the dash) instead of clipping the
|
||||
// end time.
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
)
|
||||
@@ -769,8 +823,8 @@ private fun WeekLoading() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun minToHm(min: Int): String =
|
||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
@@ -50,6 +50,14 @@ class WeekViewModel @Inject constructor(
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
private val locale: Locale = Locale.getDefault()
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -22,9 +25,7 @@ import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
|
||||
/** How far ahead the agenda widget loads (a month of upcoming events). */
|
||||
private const val AGENDA_WIDGET_DAYS = 30
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* How far either side of today the month widget pre-loads. The displayed month
|
||||
@@ -46,7 +47,28 @@ internal fun Context.hasCalendarPermission(): Boolean =
|
||||
sealed interface AgendaWidgetData {
|
||||
/** Calendar permission not granted — the widget can't read events. */
|
||||
data object NeedsPermission : AgendaWidgetData
|
||||
data class Ready(val today: LocalDate, val days: List<AgendaDay>) : AgendaWidgetData
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
/**
|
||||
* Days with events across the **widest** selectable window. The displayed
|
||||
* range is sliced from this in the composition (read reactively from
|
||||
* Glance state), so a range change is pure recomposition rather than a
|
||||
* flaky widget session restart — the same approach the month widget uses.
|
||||
* Kept unfiltered: hiding/dimming finished events also happens reactively
|
||||
* in the composition, against [now], for the same reliable-update reason.
|
||||
*/
|
||||
val days: List<AgendaDay>,
|
||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||
val is24Hour: Boolean,
|
||||
/** First day of the week, for the calendar-aligned "this week" range. */
|
||||
val weekStart: DayOfWeek,
|
||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||
val savedRange: AgendaRange,
|
||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||
val savedPastDisplay: PastEventDisplay,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,9 +113,27 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val repo = widgetEntryPoint().calendarRepository()
|
||||
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
||||
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
||||
val ep = widgetEntryPoint()
|
||||
val prefs = ep.settingsPrefs()
|
||||
val savedRange = prefs.agendaWidgetRange.first()
|
||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
// Load the widest selectable window once; the displayed range is sliced in
|
||||
// the composition from Glance state, so changing the range is a plain
|
||||
// recomposition and never depends on the widget session restarting.
|
||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
||||
val instances = ep.calendarRepository().instances(window).first()
|
||||
val is24Hour = prefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||
return AgendaWidgetData.Ready(
|
||||
today = anchor,
|
||||
days = groupAgendaDays(anchor, instances, zone),
|
||||
is24Hour = is24Hour,
|
||||
weekStart = weekStart,
|
||||
savedRange = savedRange,
|
||||
savedPastDisplay = savedPastDisplay,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
@@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
@@ -40,7 +43,16 @@ import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
@@ -61,8 +73,28 @@ import java.util.Locale
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
/**
|
||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||
* The range is read reactively in the composition ([currentState]) so a settings
|
||||
* change is reflected by plain recomposition — `updateAll` does NOT reliably
|
||||
* re-run the `provideGlance` preamble for a live widget session, so loading the
|
||||
* range there and slicing it would silently keep the stale value (mirrors the
|
||||
* month widget's MONTH_INDEX_KEY approach).
|
||||
*/
|
||||
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
||||
|
||||
/**
|
||||
* Per-instance Glance state key holding the past-event display mode (as
|
||||
* [PastEventDisplay.name]). Read reactively in the composition for the same
|
||||
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
|
||||
* widget without depending on the `provideGlance` preamble re-running.
|
||||
*/
|
||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val data = context.loadAgendaWidgetData()
|
||||
val dark = (context.resources.configuration.uiMode and
|
||||
@@ -100,12 +132,36 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||
is AgendaWidgetData.Ready ->
|
||||
if (data.days.isEmpty()) {
|
||||
is AgendaWidgetData.Ready -> {
|
||||
// Range read reactively from per-instance Glance state (falls back
|
||||
// to the saved pref for a freshly placed widget), then the wide
|
||||
// pre-loaded window is sliced to it — pure recomposition.
|
||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||
val pastDisplay =
|
||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||
val rangeEnd = data.today.plus(
|
||||
range.dayCount(data.today, data.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
// Slice to the chosen range, then drop finished events (and any day
|
||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||
val visibleDays = data.days
|
||||
.filter { it.date <= rangeEnd }
|
||||
.let { days ->
|
||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
days
|
||||
}
|
||||
}
|
||||
if (visibleDays.isEmpty()) {
|
||||
WidgetMessage(R.string.agenda_empty_title)
|
||||
} else {
|
||||
val rows = buildList {
|
||||
data.days.forEach { day ->
|
||||
visibleDays.forEach { day ->
|
||||
add(AgendaRow.Header(day.date, data.today))
|
||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||
}
|
||||
@@ -114,11 +170,18 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
items(rows.size) { index ->
|
||||
when (val row = rows[index]) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||
is AgendaRow.Event -> EventRow(row.event, dark)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
dark = dark,
|
||||
is24Hour = data.is24Hour,
|
||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||
row.event.hasEnded(data.now),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,25 +248,36 @@ 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),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||
val stripeColor = pastelize(event.color, dark).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -214,17 +288,17 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||
.width(5.dp)
|
||||
.height(36.dp)
|
||||
.cornerRadius(3.dp)
|
||||
.background(pastelize(event.color, dark)),
|
||||
.background(stripeColor),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event),
|
||||
text = eventTimeSummary(context, event, is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||
)
|
||||
@@ -263,17 +337,17 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
private fun eventTimeSummary(context: Context, event: EventInstance): String {
|
||||
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
|
||||
val time = if (event.isAllDay) {
|
||||
context.getString(R.string.event_detail_all_day)
|
||||
} else {
|
||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant): String {
|
||||
private fun formatTime(instant: Instant, is24Hour: Boolean): String {
|
||||
val t = instant.toLocalDateTime(zone()).time
|
||||
return "%02d:%02d".format(t.hour, t.minute)
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -230,8 +230,7 @@
|
||||
<string name="agenda_today_action">Heute</string>
|
||||
<string name="agenda_header_today">Heute</string>
|
||||
<string name="agenda_header_tomorrow">Morgen</string>
|
||||
<string name="agenda_empty_title">Nichts geplant</string>
|
||||
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
|
||||
<string name="agenda_empty_title">Alles erledigt</string>
|
||||
|
||||
<!-- Terminsuche -->
|
||||
<string name="search_action">Suchen</string>
|
||||
@@ -276,10 +275,35 @@
|
||||
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
||||
<string name="settings_week_start">Wochenstart</string>
|
||||
<string name="settings_week_start_auto">Automatisch</string>
|
||||
<string name="settings_week_start_monday">Montag</string>
|
||||
<string name="settings_week_start_sunday">Sonntag</string>
|
||||
<string name="settings_time_format">Zeitformat</string>
|
||||
<string name="settings_time_format_auto">Automatisch</string>
|
||||
<string name="settings_time_format_12h">12-Stunden (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
|
||||
<string name="settings_hour_lines">Stundenlinien</string>
|
||||
<string name="settings_hour_lines_summary">Trennlinie zu jeder vollen Stunde in Wochen- und Tagesansicht anzeigen</string>
|
||||
<string name="settings_agenda_range">Agenda-Zeitraum</string>
|
||||
<string name="settings_agenda_range_hint">Wie weit im Voraus die Agenda-Ansicht Termine anzeigt.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string>
|
||||
<string name="settings_agenda_widget_range_hint">Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt.</string>
|
||||
<string name="settings_agenda_range_bar">Zeitraum-Leiste</string>
|
||||
<string name="settings_agenda_range_bar_hint">Eine Leiste oben in der Agenda anzeigen, die den dargestellten Zeitraum benennt, mit einer Schaltfläche zum Wechseln des Zeitraums für die Sitzung</string>
|
||||
<string name="agenda_range_day">Heute</string>
|
||||
<string name="agenda_range_this_week">Diese Woche</string>
|
||||
<string name="agenda_range_this_month">Dieser Monat</string>
|
||||
<string name="agenda_range_week">Nächste 7 Tage</string>
|
||||
<string name="agenda_range_month">Nächste 30 Tage</string>
|
||||
<string name="agenda_range_custom">Benutzerdefiniert…</string>
|
||||
<string name="agenda_range_custom_hint">Tage</string>
|
||||
<string name="agenda_range_override_hint">Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt.</string>
|
||||
<string name="agenda_range_showing_label">Alle anstehenden Termine für</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d Tag</item>
|
||||
<item quantity="other">%d Tage</item>
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">Termin-Formular</string>
|
||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||
<string name="settings_autofocus_title">Titel bei neuem Termin fokussieren</string>
|
||||
<string name="settings_autofocus_title_hint">Beim Anlegen eines neuen Termins den Cursor direkt ins Titelfeld setzen und die Tastatur öffnen.</string>
|
||||
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
|
||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||
@@ -292,7 +316,6 @@
|
||||
<string name="reminder_none">Keine</string>
|
||||
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
|
||||
<string name="reminder_custom_amount">Anzahl</string>
|
||||
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
|
||||
<string name="reminder_custom_set">Übernehmen</string>
|
||||
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
|
||||
@@ -315,6 +338,7 @@
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Quellcode</string>
|
||||
<string name="settings_about_support">Entwicklung unterstützen</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||
<string name="settings_report_problem">Problem melden</string>
|
||||
|
||||
@@ -197,6 +197,14 @@
|
||||
<item quantity="one">%d hour</item>
|
||||
<item quantity="other">%d hours</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d week</item>
|
||||
<item quantity="other">%d weeks</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
@@ -231,8 +239,7 @@
|
||||
<string name="agenda_today_action">Today</string>
|
||||
<string name="agenda_header_today">Today</string>
|
||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||
<string name="agenda_empty_title">Nothing scheduled</string>
|
||||
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
|
||||
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||
|
||||
<!-- Event search -->
|
||||
<string name="search_action">Search</string>
|
||||
@@ -264,14 +271,47 @@
|
||||
<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>
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<string name="settings_week_start_monday">Monday</string>
|
||||
<string name="settings_week_start_sunday">Sunday</string>
|
||||
<string name="settings_time_format">Time format</string>
|
||||
<string name="settings_time_format_auto">Automatic</string>
|
||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||
<string name="settings_hour_lines">Hour lines</string>
|
||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||
<string name="settings_dim_completed">Dim completed events</string>
|
||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||
<string name="settings_past_events">Past events</string>
|
||||
<string name="settings_past_events_show">Show</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
<string name="settings_past_events_hide">Hide</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Agenda range</string>
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||
<string name="agenda_range_day">Today</string>
|
||||
<string name="agenda_range_this_week">This week</string>
|
||||
<string name="agenda_range_this_month">This month</string>
|
||||
<string name="agenda_range_week">Next 7 days</string>
|
||||
<string name="agenda_range_month">Next 30 days</string>
|
||||
<string name="agenda_range_custom">Custom…</string>
|
||||
<string name="agenda_range_custom_hint">Days</string>
|
||||
<string name="agenda_range_override_hint">Just for now — resets to your saved range when you reopen Calendula.</string>
|
||||
<string name="agenda_range_showing_label">Showing all upcoming events for</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">New event form</string>
|
||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
|
||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
@@ -284,8 +324,8 @@
|
||||
<string name="reminder_none">None</string>
|
||||
<string name="reminder_use_default">Use default reminder</string>
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
<string name="reminder_custom_with_value">Custom (%1$s)</string>
|
||||
<string name="reminder_custom_set">Set</string>
|
||||
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
||||
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||
@@ -298,8 +338,10 @@
|
||||
<string name="settings_section_language">Language</string>
|
||||
<string name="settings_language">App language</string>
|
||||
<string name="settings_language_auto">System default</string>
|
||||
<string name="settings_translate">Help translate</string>
|
||||
<string name="settings_translate_hint">Add or improve a language on Weblate</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>
|
||||
@@ -307,6 +349,7 @@
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Source</string>
|
||||
<string name="settings_about_support">Support development</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Calendula app icon</string>
|
||||
<string name="settings_report_problem">Report a problem</string>
|
||||
@@ -317,9 +360,14 @@
|
||||
<string name="calendars_local_header">Your calendars</string>
|
||||
<string name="calendars_local_empty">No local calendars yet. Create one to keep events on this device only.</string>
|
||||
<string name="calendars_add">Add calendar</string>
|
||||
<string name="calendars_disable_hint">Turn a calendar off to remove it from the app — its events, filters and pickers. Nothing is deleted, and you can turn it back on here anytime.</string>
|
||||
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
|
||||
<string name="calendars_synced_header">Synced calendars</string>
|
||||
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||
<string name="calendars_manage_in_app">Manage</string>
|
||||
<string name="calendars_manage_in_app">Manage in app</string>
|
||||
<string name="calendars_account_menu_a11y">More options for %1$s</string>
|
||||
<string name="calendars_enable_all">Enable all</string>
|
||||
<string name="calendars_disable_all">Disable all</string>
|
||||
<string name="calendars_add_account">Add account</string>
|
||||
<string name="calendars_new_title">New calendar</string>
|
||||
<string name="calendars_edit_title">Edit calendar</string>
|
||||
@@ -333,6 +381,26 @@
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
||||
<string name="calendars_backup_action">Export as .ics file</string>
|
||||
<string name="calendars_auto_backup">Automatic backup</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Tap to choose a folder</string>
|
||||
<string name="calendars_auto_backup_interval">Interval</string>
|
||||
<string name="calendars_auto_backup_every">Every %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_status_never">No automatic backup yet</string>
|
||||
<string name="calendars_auto_backup_status_ok">Last backup: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Last backup failed: %1$s</string>
|
||||
<string name="backup_channel_name">Backup</string>
|
||||
<string name="backup_channel_description">Warns if automatic backups fail repeatedly.</string>
|
||||
<string name="backup_failed_title">Automatic backup failed</string>
|
||||
<string name="backup_failed_text">Calendula couldn\'t write the backup file. Check the backup folder in Settings.</string>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Exported %d event.</item>
|
||||
@@ -377,6 +445,8 @@
|
||||
|
||||
<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>
|
||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
|
||||
|
||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||
Gitea issue (the app sends nothing automatically). -->
|
||||
|
||||
@@ -170,6 +170,74 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances drops events whose calendar the user disabled`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.setDisabledCalendarIds(setOf(2L))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "Enabled", calendarId = 1L),
|
||||
makeEvent(11L, "Disabled", calendarId = 2L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("Enabled")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances applies the union of hidden and disabled sets`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
prefs.setHiddenCalendarIds(setOf(2L))
|
||||
prefs.setDisabledCalendarIds(setOf(3L))
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "Shown", calendarId = 1L),
|
||||
makeEvent(11L, "Hidden", calendarId = 2L),
|
||||
makeEvent(12L, "Disabled", calendarId = 3L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("Shown")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `instances re-emits when the disabled set changes`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = newPrefs(tempDir)
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
instancesResult = { _, _ ->
|
||||
listOf(
|
||||
makeEvent(10L, "A", calendarId = 1L),
|
||||
makeEvent(11L, "B", calendarId = 2L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, newSettings(tempDir), UnconfinedTestDispatcher(testScheduler))
|
||||
|
||||
val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L)
|
||||
repo.instances(range).test {
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A", "B").inOrder()
|
||||
|
||||
prefs.setDisabledCalendarIds(setOf(2L))
|
||||
|
||||
assertThat(awaitItem().map { it.title }).containsExactly("A")
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
||||
|
||||
@@ -199,11 +199,12 @@ class EventWriteMapperTest {
|
||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||
assertThat(values[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||
// A single occurrence never carries its own rule.
|
||||
// The occurrence's length travels as DURATION, never DTEND — the provider
|
||||
// rejects DTEND on an exception ("Exceptions can't overwrite dtend") and
|
||||
// derives the end from DTSTART + DURATION, clearing the inherited RRULE.
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -50,4 +50,34 @@ class CalendarPrefsTest {
|
||||
}
|
||||
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabledCalendarIds defaults to empty when unset`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setDisabledCalendarIds round-trips through DataStore`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
prefs.setDisabledCalendarIds(setOf(1L, 42L, 7L))
|
||||
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(1L, 42L, 7L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setting empty disabled set clears storage`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
prefs.setDisabledCalendarIds(setOf(1L))
|
||||
prefs.setDisabledCalendarIds(emptySet())
|
||||
assertThat(prefs.disabledCalendarIds.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden and disabled sets are stored independently`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = CalendarPrefs(newDataStore(tempDir))
|
||||
prefs.setHiddenCalendarIds(setOf(1L))
|
||||
prefs.setDisabledCalendarIds(setOf(2L))
|
||||
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L))
|
||||
assertThat(prefs.disabledCalendarIds.first()).isEqualTo(setOf(2L))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -25,7 +26,7 @@ class SettingsPrefsTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
||||
assertThat(prefs.dynamicColor.first()).isTrue()
|
||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO)
|
||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -43,10 +44,70 @@ class SettingsPrefsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `week start round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
fun `week start round-trips any day`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setWeekStart(WeekStartPref.SUNDAY)
|
||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY)
|
||||
prefs.setWeekStart(WeekStartPref.Day(DayOfWeek.SATURDAY))
|
||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.SATURDAY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy MONDAY week-start value migrates to Day`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.WEEK_START_KEY] = "MONDAY"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.MONDAY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage week-start value falls back to Auto`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.WEEK_START_KEY] = "FUNDAY"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `time format defaults to auto and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.timeFormat.first()).isEqualTo(TimeFormatPref.AUTO)
|
||||
prefs.setTimeFormat(TimeFormatPref.TWELVE_HOUR)
|
||||
assertThat(prefs.timeFormat.first()).isEqualTo(TimeFormatPref.TWELVE_HOUR)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.showHourLines.first()).isFalse()
|
||||
prefs.setShowHourLines(true)
|
||||
assertThat(prefs.showHourLines.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.autofocusEventTitle.first()).isTrue()
|
||||
prefs.setAutofocusEventTitle(false)
|
||||
assertThat(prefs.autofocusEventTitle.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
|
||||
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
|
||||
|
||||
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
|
||||
prefs.setAgendaWidgetRange(AgendaRange.Week)
|
||||
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
|
||||
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -122,22 +183,38 @@ class SettingsPrefsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
|
||||
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setDefaultReminderMinutes(30)
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30)
|
||||
prefs.setDefaultReminderMinutes(null)
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
prefs.setDefaultReminderMinutes(listOf(30))
|
||||
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
|
||||
// Several lead times persist, normalised to distinct ascending order.
|
||||
prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30))
|
||||
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder()
|
||||
prefs.setDefaultReminderMinutes(emptyList())
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest {
|
||||
fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
// The pre-multi-reminder format stored a bare integer string.
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
@@ -145,7 +222,7 @@ class SettingsPrefsTest {
|
||||
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
||||
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -153,16 +230,16 @@ class SettingsPrefsTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
||||
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
|
||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
|
||||
prefs.perCalendarReminderOverride.first().let { map ->
|
||||
assertThat(map).containsExactly(7L, 15, 9L, null)
|
||||
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
|
||||
}
|
||||
|
||||
// Inherit drops the override entirely (absent != null value).
|
||||
// Inherit drops the override entirely (absent != an empty-list value).
|
||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
|
||||
prefs.perCalendarReminderOverride.first().let { map ->
|
||||
assertThat(map).containsExactly(7L, 15)
|
||||
assertThat(map).containsExactly(7L, listOf(15, 10_080))
|
||||
assertThat(map.containsKey(9L)).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -170,48 +247,48 @@ class SettingsPrefsTest {
|
||||
@Test
|
||||
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
||||
prefs.setDefaultAllDayReminderMinutes(1_440)
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440)
|
||||
prefs.setDefaultAllDayReminderMinutes(null)
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
|
||||
prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
|
||||
prefs.setDefaultAllDayReminderMinutes(emptyList())
|
||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(15))
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(1_440))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440)
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
|
||||
// Clearing the all-day override leaves the timed one untouched.
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveDefaultReminder picks the kind-matching override or global`() {
|
||||
val timed = mapOf(7L to 15, 9L to null)
|
||||
val allDay = mapOf(7L to 2_880)
|
||||
val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList<Int>())
|
||||
val allDay = mapOf(7L to listOf(2_880))
|
||||
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
|
||||
timedGlobal = 30,
|
||||
allDayGlobal = 1_440,
|
||||
timedGlobal = listOf(30),
|
||||
allDayGlobal = listOf(1_440),
|
||||
timedOverrides = timed,
|
||||
allDayOverrides = allDay,
|
||||
calendarId = calendarId,
|
||||
isAllDay = isAllDay,
|
||||
)
|
||||
// Timed: minutes override, explicit none, inherit global, no calendar.
|
||||
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15)
|
||||
assertThat(resolve(9L, isAllDay = false)).isNull()
|
||||
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30)
|
||||
assertThat(resolve(null, isAllDay = false)).isEqualTo(30)
|
||||
// Timed: multi-value override, explicit none, inherit global, no calendar.
|
||||
assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder()
|
||||
assertThat(resolve(9L, isAllDay = false)).isEmpty()
|
||||
assertThat(resolve(5L, isAllDay = false)).containsExactly(30)
|
||||
assertThat(resolve(null, isAllDay = false)).containsExactly(30)
|
||||
// All-day: its own override wins; absent → all-day global; a timed-only
|
||||
// override (cal 9) does not bleed into all-day.
|
||||
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880)
|
||||
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440)
|
||||
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440)
|
||||
assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880)
|
||||
assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440)
|
||||
assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -252,13 +329,15 @@ class SettingsPrefsTest {
|
||||
|
||||
@Test
|
||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
||||
assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY)
|
||||
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
|
||||
.isEqualTo(DayOfWeek.MONDAY)
|
||||
assertThat(WeekStartPref.Day(DayOfWeek.SATURDAY).resolveFirstDay(Locale.GERMANY))
|
||||
.isEqualTo(DayOfWeek.SATURDAY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `auto week start follows the locale convention`() {
|
||||
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,24 @@ class ReminderTimeTextTest {
|
||||
isAllDay = false,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
)
|
||||
assertThat(text).isEqualTo("09:30 – 10:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `12-hour preference renders an am-pm time range`() {
|
||||
val text = reminderTimeText(
|
||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
|
||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
|
||||
isAllDay = false,
|
||||
zone = berlin,
|
||||
locale = Locale.US,
|
||||
is24Hour = false,
|
||||
)
|
||||
assertThat(text).isEqualTo("2:00 PM – 3:00 PM")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timed event crossing midnight includes both dates`() {
|
||||
val text = reminderTimeText(
|
||||
@@ -38,6 +52,7 @@ class ReminderTimeTextTest {
|
||||
isAllDay = false,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
)
|
||||
assertThat(text).contains("11.06.2026")
|
||||
assertThat(text).contains("12.06.2026")
|
||||
@@ -54,6 +69,7 @@ class ReminderTimeTextTest {
|
||||
// 02:00 in Berlin — naive local reading would shift the day.
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
)
|
||||
assertThat(text).isEqualTo("11.06.2026")
|
||||
}
|
||||
@@ -66,6 +82,7 @@ class ReminderTimeTextTest {
|
||||
isAllDay = true,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
)
|
||||
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
||||
}
|
||||
@@ -79,6 +96,7 @@ class ReminderTimeTextTest {
|
||||
isAllDay = true,
|
||||
zone = berlin,
|
||||
locale = Locale.GERMANY,
|
||||
is24Hour = true,
|
||||
)
|
||||
assertThat(text).isEqualTo("11.06.2026")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AgendaRangeTest {
|
||||
|
||||
// A reference anchor: 2026-06-17 is a Wednesday.
|
||||
private val wednesday = LocalDate(2026, 6, 17)
|
||||
|
||||
@Test
|
||||
fun `rolling ranges have fixed 1, 7, 30 day counts regardless of anchor`() {
|
||||
assertThat(AgendaRange.Day.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(1)
|
||||
assertThat(AgendaRange.Week.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(7)
|
||||
assertThat(AgendaRange.Month.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this week runs through the day before the week repeats`() {
|
||||
// Mon-start week: Wed → Wed,Thu,Fri,Sat,Sun = 5 days (everything before next Monday).
|
||||
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(5)
|
||||
// On the first day of the week the whole 7 days remain.
|
||||
val monday = LocalDate(2026, 6, 15)
|
||||
assertThat(AgendaRange.ThisWeek.dayCount(monday, DayOfWeek.MONDAY)).isEqualTo(7)
|
||||
// A Sunday-start week shifts the boundary: Wed → 4 days left (through Saturday).
|
||||
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.SUNDAY)).isEqualTo(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this month runs through the last day of the month`() {
|
||||
// June has 30 days: the 17th leaves 14 days (17..30 inclusive).
|
||||
assertThat(AgendaRange.ThisMonth.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(14)
|
||||
// February 2026 (non-leap) has 28 days.
|
||||
assertThat(AgendaRange.ThisMonth.dayCount(LocalDate(2026, 2, 20), DayOfWeek.MONDAY))
|
||||
.isEqualTo(9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom day count is clamped to bounds`() {
|
||||
assertThat(AgendaRange.Custom(45).dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(45)
|
||||
assertThat(AgendaRange.Custom(0).dayCount(wednesday, DayOfWeek.MONDAY))
|
||||
.isEqualTo(AgendaRange.MIN_CUSTOM_DAYS)
|
||||
assertThat(AgendaRange.Custom(9_999).dayCount(wednesday, DayOfWeek.MONDAY))
|
||||
.isEqualTo(AgendaRange.MAX_CUSTOM_DAYS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fixed ranges round-trip through storage`() {
|
||||
listOf(
|
||||
AgendaRange.Day,
|
||||
AgendaRange.Week,
|
||||
AgendaRange.Month,
|
||||
AgendaRange.ThisWeek,
|
||||
AgendaRange.ThisMonth,
|
||||
).forEach { range ->
|
||||
assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day))
|
||||
.isEqualTo(range)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom range round-trips through storage`() {
|
||||
val range = AgendaRange.Custom(14)
|
||||
assertThat(range.storageValue()).isEqualTo("CUSTOM:14")
|
||||
assertThat(parseAgendaRange("CUSTOM:14", default = AgendaRange.Day))
|
||||
.isEqualTo(AgendaRange.Custom(14))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage and null fall back to the default`() {
|
||||
assertThat(parseAgendaRange(null, default = AgendaRange.Month)).isEqualTo(AgendaRange.Month)
|
||||
assertThat(parseAgendaRange("YEAR", default = AgendaRange.Week)).isEqualTo(AgendaRange.Week)
|
||||
assertThat(parseAgendaRange("CUSTOM:abc", default = AgendaRange.Day)).isEqualTo(AgendaRange.Day)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out-of-range custom value clamps on parse`() {
|
||||
assertThat(parseAgendaRange("CUSTOM:9999", default = AgendaRange.Day))
|
||||
.isEqualTo(AgendaRange.Custom(AgendaRange.MAX_CUSTOM_DAYS))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.Locale
|
||||
|
||||
class TimeFormatTest {
|
||||
|
||||
@Test
|
||||
fun `24-hour format is zero-padded HH mm`() {
|
||||
assertThat(formatTimeOfDay(9, 5, is24Hour = true, Locale.US)).isEqualTo("09:05")
|
||||
assertThat(formatTimeOfDay(14, 0, is24Hour = true, Locale.US)).isEqualTo("14:00")
|
||||
assertThat(formatTimeOfDay(0, 0, is24Hour = true, Locale.US)).isEqualTo("00:00")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `12-hour format adds a localised am-pm marker`() {
|
||||
assertThat(formatTimeOfDay(14, 0, is24Hour = false, Locale.US)).isEqualTo("2:00 PM")
|
||||
assertThat(formatTimeOfDay(0, 30, is24Hour = false, Locale.US)).isEqualTo("12:30 AM")
|
||||
assertThat(formatTimeOfDay(12, 0, is24Hour = false, Locale.US)).isEqualTo("12:00 PM")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minute-of-day end-of-day renders 24 00 in 24h and midnight in 12h`() {
|
||||
assertThat(formatMinuteOfDay(1_440, is24Hour = true, Locale.US)).isEqualTo("24:00")
|
||||
assertThat(formatMinuteOfDay(1_440, is24Hour = false, Locale.US)).isEqualTo("12:00 AM")
|
||||
assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hour label is zero-padded in 24h and compact am-pm in 12h`() {
|
||||
assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13")
|
||||
assertThat(formatHourLabel(13, is24Hour = false, Locale.US)).isEqualTo("1 PM")
|
||||
assertThat(formatHourLabel(1, is24Hour = false, Locale.US)).isEqualTo("1 AM")
|
||||
assertThat(formatHourLabel(0, is24Hour = false, Locale.US)).isEqualTo("12 AM")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
24
docs/BUILDING.md
Normal file
24
docs/BUILDING.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Building from source
|
||||
|
||||
Calendula builds with the standard Android Gradle toolchain — no extra setup
|
||||
beyond the SDK and a JDK.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Android SDK 36+**
|
||||
- **JDK 17** — if your default JDK is not 17, set `JAVA_HOME` explicitly.
|
||||
|
||||
The Gradle wrapper is checked in, so you don't need a system Gradle.
|
||||
|
||||
## Common tasks
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug # debug APK → app/build/outputs/apk/debug/
|
||||
./gradlew test # JVM unit tests
|
||||
./gradlew lint # Android lint
|
||||
```
|
||||
|
||||
## Release builds
|
||||
|
||||
Release signing and the publishing pipeline are documented separately in
|
||||
[RELEASING.md](RELEASING.md).
|
||||
@@ -21,6 +21,7 @@ AuthorName: Jean-Luc Makiola
|
||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
||||
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
|
||||
Donate: https://ko-fi.com/jeanlucmakiola
|
||||
|
||||
AutoName: Calendula
|
||||
|
||||
|
||||
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]).
|
||||
|
||||
14
fastlane/metadata/android/en-US/changelogs/21000.txt
Normal file
14
fastlane/metadata/android/en-US/changelogs/21000.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
### Added
|
||||
- Turn a calendar off across the whole app. Settings → Calendars now has a
|
||||
switch on every calendar — both your own and synced ones. Switching one off
|
||||
removes it everywhere: its events disappear from all views and search, and it
|
||||
drops out of the drawer's hide/show filter, the event-form calendar picker and
|
||||
the import target picker. Unlike hiding (the quick per-view checkbox in the
|
||||
drawer), a disabled calendar leaves the app entirely until you turn it back on.
|
||||
Nothing is deleted and no other app is affected — it's a Calendula-only view
|
||||
choice — so you can re-enable it any time from the same screen, where disabled
|
||||
calendars stay listed but dimmed.
|
||||
- An optional way to support development. Settings → About now has a "Support
|
||||
development" button that opens Ko-fi in your browser. It's a plain donation
|
||||
link with no perks attached, and it needs no new permissions — Calendula still
|
||||
has no internet access of its own and just hands the link to your browser.
|
||||
50
fastlane/metadata/android/en-US/changelogs/21100.txt
Normal file
50
fastlane/metadata/android/en-US/changelogs/21100.txt
Normal file
@@ -0,0 +1,50 @@
|
||||
### Added
|
||||
- Start the week on any day. The **Week starts on** setting (Settings →
|
||||
Appearance) now offers every weekday — not just Monday or Sunday — alongside
|
||||
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
|
||||
([#3]).
|
||||
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
|
||||
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
|
||||
follow the system setting automatically. It applies everywhere times appear —
|
||||
the week and day timelines, agenda, event details, search and reminders — so
|
||||
the format is now consistent across the whole app. Thanks to @zmaherdev for
|
||||
the suggestion ([#6]).
|
||||
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
|
||||
Appearance) draws a faint separator at each hour in the week and day views,
|
||||
making it easier to see when events start and end. Off by default. Thanks to
|
||||
@zmaherdev for the suggestion ([#5]).
|
||||
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
|
||||
range** and **Agenda widget range** settings (Settings → Appearance) let the
|
||||
agenda screen and its home-screen widget each show just today, the rest of this
|
||||
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
|
||||
days — "This week" follows your week-start. The agenda also gains a bar at the
|
||||
top naming the exact dates in view, with a button to switch the range just for
|
||||
the current session (it resets when you reopen the app); the bar can be turned
|
||||
off. Thanks to @zmaherdev for the suggestion ([#4]).
|
||||
- Automatic backup of local calendars. A new **Automatic backup** option
|
||||
(Settings → Calendars → Backup) periodically exports your local calendars to an
|
||||
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
|
||||
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
|
||||
is a one-way export and never touches your synced accounts. Thanks to @shield
|
||||
for the discussion that inspired it ([#7], [#8]).
|
||||
- Field icons in the event-form settings. Each optional-field toggle (Settings →
|
||||
Event form) now shows the same icon the field uses in the new-event form, so
|
||||
the list is easier to scan.
|
||||
- Help translate Calendula. A **Help translate** link at the top of **Settings →
|
||||
App language** opens the project's Weblate, where you can add or improve a
|
||||
language in your browser — no coding needed. Contributions in any language are
|
||||
welcome.
|
||||
|
||||
### Changed
|
||||
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
||||
agenda), and the agenda's empty state now reads "You're all caught up".
|
||||
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
|
||||
into collapsible, source-branded cards — each account shows its app's icon —
|
||||
with a per-account menu to enable or disable all of its calendars at once or
|
||||
open the account in its source app.
|
||||
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
|
||||
moved up with the other global reminder options, and the per-calendar reminder
|
||||
overrides now fold into a single expandable section.
|
||||
- Moved **Add Quick Settings tile** out of the event-form section to its own
|
||||
top-level entry in Settings.
|
||||
|
||||
6
fastlane/metadata/android/en-US/changelogs/21101.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/21101.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
### Fixed
|
||||
- Calendula can now be set as your default calendar app. It registers the
|
||||
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||
that day. Android has no way for an app to make itself the default, so you pick
|
||||
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||
7
fastlane/metadata/android/en-US/changelogs/21102.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/21102.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
### Added
|
||||
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||
the cursor in the title field and opens the keyboard right away, so you can type
|
||||
the name without an extra tap. It's on by default and only affects creating an
|
||||
event — editing never grabs focus — and a new **Focus title on new event**
|
||||
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||
suggestion ([#10]).
|
||||
19
fastlane/metadata/android/en-US/changelogs/21200.txt
Normal file
19
fastlane/metadata/android/en-US/changelogs/21200.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
### Added
|
||||
- See past events your way. Two new settings change how events that have already
|
||||
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||
(Settings → Appearance) separately fades finished events in the month and week
|
||||
views. Both are off by default, an event only counts as finished once it has
|
||||
actually ended (events still in progress are never dimmed), and the lists
|
||||
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||
([#12]).
|
||||
|
||||
### Changed
|
||||
- Readable titles on overlapping events. In the week and day views, events that
|
||||
overlap split a day into slim columns where the title used to be clipped to a
|
||||
character or two. The title now wraps across as many lines as the block can
|
||||
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||
for the suggestion ([#13]).
|
||||
|
||||
@@ -8,3 +8,4 @@ Categories:
|
||||
|
||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
||||
Donate: https://ko-fi.com/jeanlucmakiola
|
||||
|
||||
@@ -26,10 +26,14 @@ lifecycleCompose = "2.10.0"
|
||||
androidxTestRules = "1.7.0"
|
||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||
glance = "1.1.1"
|
||||
work = "2.10.1"
|
||||
documentfile = "1.0.1"
|
||||
|
||||
[libraries]
|
||||
# AndroidX core
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
|
||||
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
|
||||
Reference in New Issue
Block a user