Compare commits
42 Commits
release/v2
...
f8efa4d3e4
| Author | SHA1 | Date | |
|---|---|---|---|
| f8efa4d3e4 | |||
| 93cc264fbf | |||
| 585cdac4ab | |||
| 9994e8c534 | |||
| 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 | |||
| faf2f27eda |
@@ -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)**
|
**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)*
|
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)*
|
**Tier 4 — reliability, data-safety & interop** *(re-ranked 2026-06-17)*
|
||||||
9. **Reminders — defaults + delivery reliability** *(shipped v2.6.0)* — global
|
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).
|
decision (snooze must re-fire an alarm).
|
||||||
|
|
||||||
**Tier 5 — close the read/write gap on the event model** *(opened 2026-06-22)*
|
**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"
|
12. **Attendee editing** *(shipped — merged into release/v2.8.0, `feat/attendee-editing`
|
||||||
bucket, owner says high-importance 2026-06-22)* — attendees are already
|
MR !29 / commit `b0f34ff`, 2026-06-22)* — closed the last big read-only gap in
|
||||||
*read* (queried, mapped, shown on the detail screen since v0.6) but the
|
the event model: attendees were already *read* (queried, mapped, shown on the
|
||||||
event form can't *write* them, the last big read-only gap in the event
|
detail screen since v0.6) and are now *writable* from the form. An attendees
|
||||||
model. Add an attendees section to `EventEditScreen` / `EventForm`: add by
|
section on `EventEditScreen` / `EventForm` adds by typed email **or** the
|
||||||
typed email (contact-picker entry can follow later), edit/remove rows, set
|
no-permission contact picker, edits/removes rows, and sets role (required /
|
||||||
role (required / optional) — writing `CalendarContract.Attendees` rows on
|
optional) — writing `CalendarContract.Attendees` rows on insert + dirty-checked
|
||||||
insert + dirty-checked update, mirroring the reminders-diff pattern.
|
update, mirroring the reminders-diff pattern. The sync-adapter *invitation*
|
||||||
**Caveat to resolve first:** writing `Attendees` rows triggers sync-adapter
|
caveat was **settled record-only** (Calendula never sends invites; the backend
|
||||||
*invitation* behavior that differs by backend (Google auto-emails invites;
|
decides delivery) — full sketch + decision in "Attendee editing" under Locations
|
||||||
DAVx5/CalDAV writes the `ATTENDEE` property and lets the server decide) —
|
& People below.
|
||||||
decide and document what Calendula promises before building. Full sketch in
|
|
||||||
"Attendee editing" under Locations & People below.
|
|
||||||
|
|
||||||
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||||
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
- 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)
|
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||||
|
|
||||||
**Bottom — deprioritised, not important**
|
**Bottom — deprioritised, not important**
|
||||||
- Duplicate event (detail action → prefilled create form) — moved here
|
- Duplicate event (detail action → prefilled create form) — moved here
|
||||||
2026-06-17; cheap but low value, pick up only if asked
|
2026-06-17; cheap but low value, pick up only if asked
|
||||||
|
|
||||||
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts.
|
||||||
full-text search. Pulled in opportunistically, not sequenced.
|
Pulled in opportunistically, not sequenced.
|
||||||
|
|
||||||
Tier 4 is now fully shipped (#9 reminders defaults v2.6.0, #10 `.ics`
|
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).
|
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
|
and Tier 5 #12 — attendee editing — shipped in release/v2.8.0, closing the last
|
||||||
in the event model). After it: the theme-group ideas and Tier 2/3 leftovers
|
read-only gap in the event model. v2.8.0 also cleared most Tier 2/3 leftovers —
|
||||||
(quick-settings tile, now-line, week numbers in month, full-text search).
|
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
|
## 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
|
- Agenda view (fourth view: upcoming events grouped by day; also the
|
||||||
natural data source for a future widget)
|
natural data source for a future widget)
|
||||||
- Jump to date — drawer date picker (un-cut from V1)
|
- Jump to date — drawer date picker (un-cut from V1)
|
||||||
- Current-time "now" line in day/week — standard in every calendar, cheap,
|
- ~~Current-time "now" line in day/week~~ **shipped v2.8.0**
|
||||||
currently absent. Daily-driver polish.
|
- Week numbers in the **month** grid — **rejected** (owner decision): clutters
|
||||||
- Week numbers in the **month** grid — week view already shows the badge
|
the view and shrinks the day cells; the badge stays week-view-only.
|
||||||
(`WeekNumberBadge`, `WeekScreen.kt`); extend to month for ISO/European users.
|
|
||||||
- Pinch-to-zoom time scale in day/week
|
- Pinch-to-zoom time scale in day/week
|
||||||
- Tablet / foldable layouts *(was v3.0)*
|
- Tablet / foldable layouts *(was v3.0)*
|
||||||
- Full-text search *(was v3.0)* — promote out of "fill-in": for a daily driver
|
- ~~Full-text search~~ **shipped v2.8.0** — for a daily driver with real event
|
||||||
with real event history, finding an event is core completeness, not optional.
|
history, finding an event is core completeness, not optional.
|
||||||
|
|
||||||
## Event editing & creation
|
## Event editing & creation
|
||||||
|
|
||||||
- Duplicate event (detail action → prefilled create form)
|
- Duplicate event (detail action → prefilled create form)
|
||||||
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
|
- ~~**Per-event color**~~ *(shipped v2.4.0)* — `EVENT_COLOR` / `EVENT_COLOR_KEY`
|
||||||
*(next)* — chosen to follow the in-progress tap-to-create + calendar
|
from the calendar's color list (`Colors` table, `TYPE_EVENT`), OptionCard
|
||||||
management work: reuses the color-picker component and palette plumbing
|
picker in the form, falling back to the calendar color when unset. Reused the
|
||||||
being built for local calendar management, and finishes the create/edit
|
color-picker component and palette plumbing from local calendar management and
|
||||||
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
|
finished the create/edit theme.
|
||||||
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
|
|
||||||
|
|
||||||
## Calendars & accounts
|
## 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
|
`.ics` file (SAF); restore is the bulk-import path (pick a calendar, dedup by
|
||||||
UID). Closed the silent data-loss gap.
|
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
|
Two themes bundled because both are "make reminders trustworthy" — the core of
|
||||||
the "Calendula is your only calendar app" promise.
|
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
|
settings so users can verify delivery on their specific OEM (Samsung / Xiaomi
|
||||||
are notorious for suppressing it).
|
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
|
1. **Provider broadcast kept** — did not self-schedule via `AlarmManager`; the
|
||||||
(reliability vs simplicity + battery cost).
|
battery-optimisation exemption is the reliability lever (simplicity + battery
|
||||||
2. All-day reminder representation (minutes-before vs absolute time-of-day).
|
cost won over the exact-alarm subsystem).
|
||||||
3. Where per-calendar overrides live in the UI (rows on the Calendars screen vs
|
2. **All-day reminders = minutes-before-midnight** (day-scale presets); absolute
|
||||||
a list inside the Notifications sub-page).
|
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
|
- ~~Snooze + dismiss actions on the notification~~ *(shipped in release/v2.8.0,
|
||||||
exact-alarm / WorkManager decision) — Tier 4 #13.
|
Tier 4 #11)* — snooze re-fires a snooze-only exact alarm.
|
||||||
|
|
||||||
## Sharing & interop
|
## Sharing & interop
|
||||||
|
|
||||||
@@ -438,8 +495,8 @@ it directly undermines the feature's premise, so it rides in here.
|
|||||||
## Platform & launchers
|
## Platform & launchers
|
||||||
|
|
||||||
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
- ~~Home-screen widget~~ **shipped v2.5.0** — agenda + month widgets
|
||||||
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0** —
|
- ~~App shortcuts (launcher long-press → New event)~~ **shipped v2.5.0**
|
||||||
optional quick-settings tile still open
|
- ~~"New event" Quick Settings tile~~ **shipped v2.8.0**
|
||||||
|
|
||||||
## Quality & reliability
|
## 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
|
Beyond classic calendar-client scope; discussed, deliberately not planned
|
||||||
in detail yet:
|
in detail yet:
|
||||||
|
|
||||||
- **Contact address picker** for the location field via the system picker
|
- ~~**Contact address picker** for the location field via the system picker
|
||||||
(`ACTION_PICK` on postal addresses) — one-shot, needs no READ_CONTACTS,
|
(`ACTION_PICK`)~~ **shipped v2.8.0** — one-shot, needs no READ_CONTACTS, fits
|
||||||
fits the privacy story. Same mechanism later for picking emails.
|
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
|
- **OSM address autocomplete** in the location field (type "Brandenburger
|
||||||
Tor" → tap suggestion → resolved address inserted). Backend would be
|
Tor" → tap suggestion → resolved address inserted). Backend would be
|
||||||
Photon (Nominatim's public policy forbids autocomplete). **Requires the
|
Photon (Nominatim's public policy forbids autocomplete). **Requires the
|
||||||
|
|||||||
82
CHANGELOG.md
82
CHANGELOG.md
@@ -5,6 +5,81 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [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. New **Agenda range** and **Agenda widget
|
||||||
|
range** settings (Settings → Appearance) let the agenda screen and its 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
|
||||||
|
preference. Thanks to @zmaherdev for the suggestion ([#4]).
|
||||||
|
|
||||||
|
## [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
|
## [2.8.0] — 2026-06-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -572,3 +647,10 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
- Gitea release workflow: signed release APK + F-Droid metadata sync to Hetzner
|
||||||
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
- F-Droid metadata stubs (DE + EN short/full descriptions)
|
||||||
- `.planning/` project-tracking documents
|
- `.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
|
||||||
|
|||||||
59
README.md
59
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>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
|
||||||
</p>
|
</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>
|
<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/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">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="19%" alt="Month view">
|
||||||
@@ -68,13 +74,20 @@ database, no sync stack reinvented.
|
|||||||
|
|
||||||
## 📦 Install
|
## 📦 Install
|
||||||
|
|
||||||
Calendula ships through a self-hosted F-Droid repository; every version tag
|
### F-Droid (recommended)
|
||||||
is built, signed, and published there automatically.
|
|
||||||
|
|
||||||
1. Install an F-Droid client ([F-Droid](https://f-droid.org), Droid-ify, Neo
|
Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
|
||||||
Store, …).
|
just search for **Calendula** in any F-Droid client, or
|
||||||
2. Add the repository — open this link on your phone, or paste it under
|
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
|
||||||
*Settings → Repositories → Add*:
|
|
||||||
|
### 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
|
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
|
||||||
@@ -84,36 +97,16 @@ is built, signed, and published there automatically.
|
|||||||
fingerprint (SHA-256):
|
fingerprint (SHA-256):
|
||||||
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
|
`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
|
2. Refresh, search for **Calendula**, install.
|
||||||
other F-Droid app.
|
|
||||||
|
|
||||||
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
|
||||||
```bash
|
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
||||||
./gradlew assembleDebug # debug APK
|
|
||||||
./gradlew test # JVM unit tests
|
|
||||||
./gradlew lint # Android lint
|
|
||||||
```
|
|
||||||
|
|
||||||
If your default JDK is not 17, set `JAVA_HOME` explicitly.
|
|
||||||
|
|
||||||
## 🏗 Architecture
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
## 📜 License
|
## 📜 License
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 20800
|
versionCode = 21100
|
||||||
versionName = "2.8.0"
|
versionName = "2.11.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".CalendulaApp"
|
android:name=".CalendulaApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.IntentCompat
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -20,8 +23,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
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.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.WidgetNavRequest
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
@@ -80,19 +87,31 @@ class MainActivity : AppCompatActivity() {
|
|||||||
ThemeMode.LIGHT -> false
|
ThemeMode.LIGHT -> false
|
||||||
ThemeMode.DARK -> true
|
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(
|
CalendulaTheme(
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
CompositionLocalProvider(
|
||||||
modifier = Modifier.fillMaxSize(),
|
LocalUse24HourFormat provides use24Hour,
|
||||||
requestedDetailKey = requestedDetailKey,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
) {
|
||||||
widgetNavRequest = requestedNav,
|
RootScreen(
|
||||||
onWidgetNavConsumed = { requestedNav = null },
|
modifier = Modifier.fillMaxSize(),
|
||||||
requestedImportUri = requestedImportUri,
|
requestedDetailKey = requestedDetailKey,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
)
|
widgetNavRequest = requestedNav,
|
||||||
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
pendingCrashReport?.let { report ->
|
pendingCrashReport?.let { report ->
|
||||||
CrashReportDialog(
|
CrashReportDialog(
|
||||||
report = report,
|
report = report,
|
||||||
@@ -140,18 +159,38 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? = when {
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||||
// Launcher long-press "New event" shortcut. Static shortcut intents
|
val source = sourceViewOrNull()
|
||||||
// can't carry typed extras, so the action alone signals create-on-today.
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
return when {
|
||||||
getBooleanExtra(EXTRA_CREATE, false) ->
|
// Launcher long-press "New event" shortcut. Static shortcut intents
|
||||||
WidgetNavRequest.Create(getStringExtra(EXTRA_DATE_ISO))
|
// can't carry typed extras, so the action alone signals create-on-today.
|
||||||
getStringExtra(EXTRA_DATE_ISO) != null ->
|
action == ACTION_NEW_EVENT -> WidgetNavRequest.Create(null)
|
||||||
WidgetNavRequest.OpenDate(getStringExtra(EXTRA_DATE_ISO)!!)
|
getBooleanExtra(EXTRA_CREATE, false) ->
|
||||||
else -> null
|
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? {
|
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)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
if (eventId == -1L) return null
|
if (eventId == -1L) return null
|
||||||
return longArrayOf(
|
return longArrayOf(
|
||||||
@@ -168,6 +207,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
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/
|
// Fired by the launcher long-press "New event" shortcut (res/xml/
|
||||||
// shortcuts.xml hardcodes this string — keep the two in sync).
|
// shortcuts.xml hardcodes this string — keep the two in sync).
|
||||||
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
const val ACTION_NEW_EVENT = "de.jeanlucmakiola.calendula.action.NEW_EVENT"
|
||||||
@@ -190,14 +233,35 @@ class MainActivity : AppCompatActivity() {
|
|||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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 {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
data = "calendula://date/$date".toUri()
|
data = "calendula://date/$date".toUri()
|
||||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
|
putExtra(EXTRA_SOURCE_VIEW, source.name)
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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). */
|
/** Open the create-event form prefilled for [date] (home-screen widgets). */
|
||||||
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
fun openCreateIntent(context: Context, date: LocalDate): Intent =
|
||||||
Intent(context, MainActivity::class.java).apply {
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
@@ -55,11 +56,12 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
.reQuery { dataSource.calendars() }
|
.reQuery { dataSource.calendars() }
|
||||||
.flowOn(io)
|
.flowOn(io)
|
||||||
|
|
||||||
// Instances are filtered by the app-side hidden-calendar set (M3): an event
|
// Instances are filtered by the app-side hidden ∪ disabled calendar sets
|
||||||
// is dropped whenever the user has hidden its calendar. Re-runs when the
|
// (M3): an event is dropped whenever the user has hidden *or* disabled its
|
||||||
// provider ticks *or* the hidden set changes — toggling a calendar in the
|
// calendar. Re-runs when the provider ticks *or* either set changes —
|
||||||
// filter sheet updates every view immediately. [calendars] stays unfiltered
|
// toggling a calendar in the filter sheet or the calendar manager updates
|
||||||
// so the filter sheet can list and re-enable hidden calendars.
|
// 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>> =
|
override fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>> =
|
||||||
combine(
|
combine(
|
||||||
ticks
|
ticks
|
||||||
@@ -71,10 +73,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
) { instances, hidden ->
|
prefs.disabledCalendarIds,
|
||||||
if (hidden.isEmpty()) instances
|
) { instances, hidden, disabled ->
|
||||||
else instances.filterNot { it.calendarId in hidden }
|
val excluded = hidden + disabled
|
||||||
}.flowOn(io)
|
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) {
|
override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) {
|
||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
@@ -82,9 +91,9 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
||||||
if (query.isBlank()) return@withContext emptyList()
|
if (query.isBlank()) return@withContext emptyList()
|
||||||
val hidden = prefs.hiddenCalendarIds.first()
|
val excluded = prefs.hiddenCalendarIds.first() + prefs.disabledCalendarIds.first()
|
||||||
dataSource.searchEvents(query)
|
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> =
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
|||||||
@@ -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
|
* The calendar the user last created an event in; preselected in the
|
||||||
* event form. Null until the first event is created.
|
* event form. Null until the first event is created.
|
||||||
@@ -53,6 +78,7 @@ class CalendarPrefs @Inject constructor(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
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")
|
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -18,18 +19,39 @@ import javax.inject.Singleton
|
|||||||
/** Light/dark override. SYSTEM follows the device setting. */
|
/** Light/dark override. SYSTEM follows the device setting. */
|
||||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
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
|
* Clock convention for time-of-day labels. AUTO follows the device's 24-hour
|
||||||
* locale's convention (e.g. Monday in DE, Sunday in en-US).
|
* system setting; the others force a 12- or 24-hour clock app-wide.
|
||||||
|
*/
|
||||||
|
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||||
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
|
is WeekStartPref.Day -> day
|
||||||
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
|
|
||||||
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
|
// 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 +76,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
|
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) {
|
suspend fun setThemeMode(mode: ThemeMode) {
|
||||||
@@ -66,7 +88,42 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,6 +301,9 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
|
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
|
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||||
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
@@ -315,6 +375,25 @@ private fun MutableMap<Long, Int?>.applyOverride(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 NONE = "none"
|
||||||
private const val ENTRY_SEP = ";"
|
private const val ENTRY_SEP = ";"
|
||||||
private const val KEY_VALUE_SEP = "="
|
private const val KEY_VALUE_SEP = "="
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
|||||||
if (settingsPrefs.remindersEnabled.first()) {
|
if (settingsPrefs.remindersEnabled.first()) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val due = alertStore.dueAlerts(now)
|
val due = alertStore.dueAlerts(now)
|
||||||
due.forEach(notifier::post)
|
due.forEach { notifier.post(it) }
|
||||||
alertStore.markFired(due.map { it.alertId }, now)
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
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.time.ZoneId
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -29,6 +32,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** 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()
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun post(alert: ReminderAlert) {
|
suspend fun post(alert: ReminderAlert) {
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
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(
|
val time = reminderTimeText(
|
||||||
beginMillis = alert.beginMillis,
|
beginMillis = alert.beginMillis,
|
||||||
endMillis = alert.endMillis,
|
endMillis = alert.endMillis,
|
||||||
isAllDay = alert.isAllDay,
|
isAllDay = alert.isAllDay,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
locale = Locale.getDefault(),
|
locale = Locale.getDefault(),
|
||||||
|
is24Hour = is24Hour,
|
||||||
)
|
)
|
||||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -25,6 +26,7 @@ fun reminderTimeText(
|
|||||||
isAllDay: Boolean,
|
isAllDay: Boolean,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
is24Hour: Boolean,
|
||||||
): String {
|
): String {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
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 begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||||
} else {
|
} else {
|
||||||
val dateTimeFormat = DateTimeFormatter
|
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||||
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||||
.withLocale(locale)
|
// applies to the time portion too.
|
||||||
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -12,15 +14,22 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
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.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
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.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
@@ -35,14 +44,24 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Clock
|
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
|
* screens. Each screen owns its own ViewModel and date anchor; the view-switcher
|
||||||
* pill in their top bars writes back here via [onSelectView].
|
* 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
|
* [requestedDetailKey] is an externally requested occurrence (a tapped
|
||||||
* reminder notification routed through MainActivity): it opens the detail
|
* reminder notification routed through MainActivity): it opens the detail
|
||||||
* overlay exactly like an event tap and is cleared via [onDetailKeyConsumed]
|
* 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
|
@Composable
|
||||||
fun CalendarHost(
|
fun CalendarHost(
|
||||||
@@ -53,15 +72,24 @@ fun CalendarHost(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
var view by rememberSaveable { mutableStateOf(CalendarView.Week) }
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
val onSelectView: (CalendarView) -> Unit = { view = it }
|
// 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.
|
// Tapping a day in the month grid opens the day view anchored to that date.
|
||||||
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val onOpenDay: (LocalDate) -> Unit = { date ->
|
val onOpenDay: (LocalDate) -> Unit = { date ->
|
||||||
pendingDayIso = date.toString()
|
pendingDayIso = date.toString()
|
||||||
view = CalendarView.Day
|
viewStack = viewStack.drillToDay()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
// The event-detail screen (S4) is a full-screen destination hoisted here so
|
||||||
@@ -156,17 +184,30 @@ fun CalendarHost(
|
|||||||
importForm = null
|
importForm = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view) or start a
|
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||||
// create. Handled once and cleared, mirroring [requestedDetailKey].
|
// 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) {
|
LaunchedEffect(widgetNavRequest) {
|
||||||
when (val req = widgetNavRequest) {
|
when (val req = widgetNavRequest) {
|
||||||
is WidgetNavRequest.OpenDate -> {
|
is WidgetNavRequest.OpenDate -> {
|
||||||
// Reveal the day view: drop any overlay that would cover it, so
|
// Drill the day view in over the widget's view: drop any overlay
|
||||||
// an external date-open doesn't land under an open Settings/form.
|
// that would cover it, so the open doesn't land under Settings/form.
|
||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
pendingDayIso = req.dateIso
|
||||||
view = CalendarView.Day
|
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
|
is WidgetNavRequest.OpenEvent -> {
|
||||||
|
// Root the stack in the widget's view, then open the occurrence
|
||||||
|
// detail over it (same key shape as a tapped event / reminder).
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
createDateIso = null
|
||||||
|
viewStack = viewBaseStack(defaultView, req.source)
|
||||||
|
val key = longArrayOf(req.eventId, req.beginMillis, req.endMillis)
|
||||||
|
heldKey = key
|
||||||
|
detailKey = key
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
is WidgetNavRequest.Create -> {
|
is WidgetNavRequest.Create -> {
|
||||||
@@ -189,41 +230,61 @@ fun CalendarHost(
|
|||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
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()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
when (view) {
|
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||||
CalendarView.Week -> WeekScreen(
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
selectedView = view,
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
onSelectView = onSelectView,
|
val viewSwitch = calendarFadeThrough()
|
||||||
onEventClick = onEventClick,
|
AnimatedContent(
|
||||||
onOpenSettings = onOpenSettings,
|
targetState = view,
|
||||||
onOpenSearch = onOpenSearch,
|
transitionSpec = { viewSwitch },
|
||||||
onCreateEvent = onCreateEvent,
|
label = "view-switch",
|
||||||
)
|
) { currentView ->
|
||||||
CalendarView.Day -> DayScreen(
|
when (currentView) {
|
||||||
selectedView = view,
|
CalendarView.Week -> WeekScreen(
|
||||||
onSelectView = onSelectView,
|
selectedView = currentView,
|
||||||
onEventClick = onEventClick,
|
onSelectView = onSelectView,
|
||||||
onOpenSettings = onOpenSettings,
|
onEventClick = onEventClick,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSettings = onOpenSettings,
|
||||||
onCreateEvent = onCreateEvent,
|
onOpenSearch = onOpenSearch,
|
||||||
initialDateIso = pendingDayIso,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = view,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
initialDateIso = pendingDayIso,
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
)
|
||||||
selectedView = view,
|
CalendarView.Month -> MonthScreen(
|
||||||
onSelectView = onSelectView,
|
selectedView = currentView,
|
||||||
onEventClick = onEventClick,
|
onSelectView = onSelectView,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenDay = onOpenDay,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSettings = onOpenSettings,
|
||||||
onCreateEvent = onCreateEvent,
|
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
|
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||||
@@ -338,3 +399,9 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persists the view back stack across config change / process death by ordinal. */
|
||||||
|
private val viewStackSaver = listSaver<List<CalendarView>, Int>(
|
||||||
|
save = { stack -> stack.map(CalendarView::ordinal) },
|
||||||
|
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supplies [CalendarHost] with the user's default startup view (M1). Held as a
|
||||||
|
* nullable [StateFlow] so the host can wait for DataStore's first emission before
|
||||||
|
* seeding its view back stack — rendering nothing for that first frame rather
|
||||||
|
* than flashing the old hard-coded Week view and then correcting it.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class CalendarHostViewModel @Inject constructor(
|
||||||
|
prefs: SettingsPrefs,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val defaultView: StateFlow<CalendarView?> = prefs.defaultView.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package de.jeanlucmakiola.calendula.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(
|
fun RootScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -51,32 +55,40 @@ fun RootScreen(
|
|||||||
onDispose { lifecycle.removeObserver(obs) }
|
onDispose { lifecycle.removeObserver(obs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPermission) {
|
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
||||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
// onboarding eases into the next screen instead of snapping. A fade carries no
|
||||||
// Null until DataStore's first emission — render nothing for that
|
// spatial motion, so it stays appropriate under "remove animations" too.
|
||||||
// frame instead of flashing the wrong screen.
|
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
||||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
if (granted) {
|
||||||
when (onboardingDone) {
|
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
||||||
true -> CalendarHost(
|
// Null until DataStore's first emission — render nothing for that
|
||||||
modifier = modifier,
|
// frame instead of flashing the wrong screen.
|
||||||
requestedDetailKey = requestedDetailKey,
|
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
||||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
||||||
widgetNavRequest = widgetNavRequest,
|
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
||||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
when (done) {
|
||||||
requestedImportUri = requestedImportUri,
|
true -> CalendarHost(
|
||||||
onImportConsumed = onImportConsumed,
|
modifier = modifier,
|
||||||
)
|
requestedDetailKey = requestedDetailKey,
|
||||||
false -> ReminderOnboardingScreen(
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
onFinished = reminderOnboarding::finish,
|
widgetNavRequest = widgetNavRequest,
|
||||||
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = onImportConsumed,
|
||||||
|
)
|
||||||
|
false -> ReminderOnboardingScreen(
|
||||||
|
onFinished = reminderOnboarding::finish,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PermissionScreen(
|
||||||
|
onGranted = { hasPermission = true },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
null -> {}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
PermissionScreen(
|
|
||||||
onGranted = { hasPermission = true },
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui
|
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.
|
* A navigation a home-screen widget asked the app to perform when launched.
|
||||||
* Parsed from the launch intent in MainActivity and consumed once by
|
* Parsed from the launch intent in MainActivity and consumed once by
|
||||||
* [CalendarHost] (event taps reuse the existing reminder detail-key channel, so
|
* [CalendarHost]. Every request carries the [source] view of the widget it came
|
||||||
* they are not modelled here).
|
* 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 {
|
sealed interface WidgetNavRequest {
|
||||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date). */
|
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
||||||
data class OpenDate(val dateIso: String) : WidgetNavRequest
|
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). */
|
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
@@ -57,6 +58,9 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
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.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -190,6 +194,7 @@ private fun AgendaList(
|
|||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
|
modifier = calendarAnimateItem(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -221,11 +226,13 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
|||||||
private fun AgendaEventRow(
|
private fun AgendaEventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
modifier = modifier,
|
||||||
title = title,
|
title = title,
|
||||||
summary = agendaTimeSummary(event),
|
summary = agendaTimeSummary(event),
|
||||||
position = position,
|
position = position,
|
||||||
@@ -334,15 +341,17 @@ private fun agendaTimeSummary(event: EventInstance): String {
|
|||||||
val time = if (event.isAllDay) {
|
val time = if (event.isAllDay) {
|
||||||
stringResource(R.string.event_detail_all_day)
|
stringResource(R.string.event_detail_all_day)
|
||||||
} else {
|
} 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() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
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
|
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 {
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.accounts.AccountManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -31,7 +30,6 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
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.FileDownload
|
||||||
import androidx.compose.material.icons.filled.OpenInNew
|
import androidx.compose.material.icons.filled.OpenInNew
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
@@ -46,6 +44,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -59,11 +58,14 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -73,6 +75,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
@@ -98,6 +101,7 @@ fun CalendarsScreen(
|
|||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -134,6 +138,7 @@ fun CalendarsScreen(
|
|||||||
CalendarsList(
|
CalendarsList(
|
||||||
local = calendars.filter { it.isLocal },
|
local = calendars.filter { it.isLocal },
|
||||||
synced = calendars.filterNot { it.isLocal },
|
synced = calendars.filterNot { it.isLocal },
|
||||||
|
disabledIds = disabledIds,
|
||||||
error = error,
|
error = error,
|
||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
backupResult = backupResult,
|
backupResult = backupResult,
|
||||||
@@ -142,6 +147,7 @@ fun CalendarsScreen(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
|
onSetDisabled = viewModel::setDisabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,6 +156,7 @@ fun CalendarsScreen(
|
|||||||
private fun CalendarsList(
|
private fun CalendarsList(
|
||||||
local: List<CalendarSource>,
|
local: List<CalendarSource>,
|
||||||
synced: List<CalendarSource>,
|
synced: List<CalendarSource>,
|
||||||
|
disabledIds: Set<Long>,
|
||||||
error: Boolean,
|
error: Boolean,
|
||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
backupResult: BackupResult?,
|
backupResult: BackupResult?,
|
||||||
@@ -158,6 +165,7 @@ private fun CalendarsList(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
|
onSetDisabled: (Long, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -206,19 +214,21 @@ private fun CalendarsList(
|
|||||||
if (local.isEmpty()) {
|
if (local.isEmpty()) {
|
||||||
HintText(stringResource(R.string.calendars_local_empty))
|
HintText(stringResource(R.string.calendars_local_empty))
|
||||||
}
|
}
|
||||||
|
HintText(stringResource(R.string.calendars_disable_hint))
|
||||||
val localCount = local.size + 1
|
val localCount = local.size + 1
|
||||||
local.forEachIndexed { index, calendar ->
|
local.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = calendar.id in disabledIds
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
summary = calendar.description,
|
summary = calendar.description,
|
||||||
position = positionOf(index, localCount),
|
position = positionOf(index, localCount),
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
dimmed = disabled,
|
||||||
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = {
|
||||||
Icon(
|
EnableSwitch(
|
||||||
Icons.Default.Edit,
|
calendarName = calendar.displayName,
|
||||||
contentDescription = stringResource(R.string.calendars_edit_title),
|
enabled = !disabled,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onEdit(calendar) },
|
onClick = { onEdit(calendar) },
|
||||||
@@ -258,10 +268,19 @@ private fun CalendarsList(
|
|||||||
.forEach { (account, cals) ->
|
.forEach { (account, cals) ->
|
||||||
AccountHeader(account = account, accountType = cals.first().accountType)
|
AccountHeader(account = account, accountType = cals.first().accountType)
|
||||||
cals.forEachIndexed { index, calendar ->
|
cals.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = calendar.id in disabledIds
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
position = positionOf(index, cals.size),
|
position = positionOf(index, cals.size),
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
dimmed = disabled,
|
||||||
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
|
trailing = {
|
||||||
|
EnableSwitch(
|
||||||
|
calendarName = calendar.displayName,
|
||||||
|
enabled = !disabled,
|
||||||
|
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,10 +314,9 @@ private fun CalendarEditor(
|
|||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
BackHandler(onBack = onClose)
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.predictiveBack(onBack = onClose)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -425,6 +443,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). */
|
/** Tonal field card matching the event editor's design (icon + content). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun EditorCard(
|
private fun EditorCard(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -33,6 +35,7 @@ import javax.inject.Inject
|
|||||||
class CalendarsViewModel @Inject constructor(
|
class CalendarsViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -46,6 +49,20 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptyList(),
|
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(),
|
||||||
|
)
|
||||||
|
|
||||||
private val _error = MutableStateFlow(false)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
@@ -93,6 +110,19 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
repository.deleteCalendar(id)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private inline fun write(crossinline block: suspend () -> Unit) {
|
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,14 +1,56 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
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.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.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.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
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.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
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.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*
|
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||||
@@ -24,17 +66,160 @@ fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
|||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
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
|
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
* (e.g. "today"); a jump reuses the forward direction.
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
* @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(
|
fun calendarSlideTransition(
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
spec: FiniteAnimationSpec<IntOffset>,
|
spec: FiniteAnimationSpec<IntOffset>,
|
||||||
|
fadeSpec: FiniteAnimationSpec<Float>,
|
||||||
|
reduceMotion: Boolean,
|
||||||
): ContentTransform {
|
): ContentTransform {
|
||||||
|
if (reduceMotion) {
|
||||||
|
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
|
}
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return slideInHorizontally(spec) { w -> dir * w }
|
||||||
.togetherWith(slideOutHorizontally(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
|
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||||
return available[(i + 1) % available.size]
|
return available[(i + 1) % available.size]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top-level view back stack (bottom → top): the [default] home view always
|
||||||
|
* sits at the bottom. Pressing back pops one level until only the home view
|
||||||
|
* remains, then the system exits the app. Three operations move it:
|
||||||
|
*
|
||||||
|
* - [selectView] — a lateral switch (the pill or the drawer). It keeps a visit
|
||||||
|
* history so back retraces it: switching to a not-yet-visited view pushes it on
|
||||||
|
* top; switching to one already in the stack pops back to it (collapsing the
|
||||||
|
* loop, so the depth stays bounded by the number of distinct views). Selecting
|
||||||
|
* the home view collapses to just `[default]`.
|
||||||
|
* - [viewBaseStack] — the stack a widget launch resets to: just `[default]` when
|
||||||
|
* the widget's view is the home view, else `[default, source]`. A widget entry
|
||||||
|
* starts a fresh context rather than extending in-app history.
|
||||||
|
* - [drillToDay] — pushes the day view on top (a date tap from the month grid or
|
||||||
|
* a widget), unless the day view is already current.
|
||||||
|
*/
|
||||||
|
fun List<CalendarView>.selectView(target: CalendarView): List<CalendarView> {
|
||||||
|
val existing = indexOf(target)
|
||||||
|
return if (existing >= 0) take(existing + 1) else this + target
|
||||||
|
}
|
||||||
|
|
||||||
|
fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarView> =
|
||||||
|
if (source == default) listOf(default) else listOf(default, source)
|
||||||
|
|
||||||
|
/** Push the day view as a drill-in over the current stack (no-op if already on it). */
|
||||||
|
fun List<CalendarView>.drillToDay(): List<CalendarView> =
|
||||||
|
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -73,11 +72,11 @@ fun CollapsingScaffold(
|
|||||||
actions: @Composable RowScope.() -> Unit = {},
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
val scrollBehavior =
|
val scrollBehavior =
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.predictiveBack(onBack = onBack)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
@@ -128,7 +127,9 @@ fun CollapsingScaffold(
|
|||||||
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
* 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
|
* 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
|
* 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -138,6 +139,7 @@ fun GroupedRow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
summary: String? = null,
|
summary: String? = null,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
|
dimmed: Boolean = false,
|
||||||
minHeight: Dp = 72.dp,
|
minHeight: Dp = 72.dp,
|
||||||
leading: @Composable (() -> Unit)? = null,
|
leading: @Composable (() -> Unit)? = null,
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
@@ -169,6 +171,16 @@ fun GroupedRow(
|
|||||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
trailingIconColor = 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 {
|
} else {
|
||||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,7 +173,11 @@ fun ReminderDefaultPicker(
|
|||||||
},
|
},
|
||||||
onClick = { customExpanded = !customExpanded },
|
onClick = { customExpanded = !customExpanded },
|
||||||
)
|
)
|
||||||
AnimatedVisibility(visible = customExpanded) {
|
AnimatedVisibility(
|
||||||
|
visible = customExpanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
CustomReminderEditor(
|
CustomReminderEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
onAmountChange = { amountText = it },
|
onAmountChange = { amountText = it },
|
||||||
|
|||||||
@@ -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.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
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.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
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 de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -237,6 +244,8 @@ private fun DayContent(
|
|||||||
val threshold = with(density) { 24.dp.toPx() }
|
val threshold = with(density) { 24.dp.toPx() }
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
|
||||||
// Hoisted above the per-day AnimatedContent so the vertical scroll position
|
// 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
|
// survives day-to-day swipes. We only centre on noon once, on first entry
|
||||||
@@ -287,7 +296,7 @@ private fun DayContent(
|
|||||||
DayUiState.Loading -> "loading"
|
DayUiState.Loading -> "loading"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
label = "day-transition",
|
label = "day-transition",
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
@@ -462,6 +471,8 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||||
@@ -484,7 +495,7 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "%02d".format(h),
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -530,6 +541,8 @@ private fun DayColumnCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
|
val showHourLines = LocalShowHourLines.current
|
||||||
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular column — the soft corners come from the outer
|
// Plain rectangular column — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -542,6 +555,9 @@ private fun DayColumnCard(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.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
|
// Tap an empty slot to create an event there. Taps on event
|
||||||
// blocks are consumed by their own click handler first, so this
|
// blocks are consumed by their own click handler first, so this
|
||||||
// only fires on the column background. Snaps to the tapped hour.
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
@@ -585,7 +601,10 @@ private fun EventBlock(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
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
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -631,8 +650,8 @@ private fun DayLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun minToHm(min: Int): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate): String {
|
private fun formatDayTitle(date: LocalDate): String {
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
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.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
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.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
@@ -132,8 +134,6 @@ fun EventDetailScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -214,6 +214,7 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.predictiveBack(onBack = onBack),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -782,7 +783,7 @@ private fun formatWhen(
|
|||||||
val zid = ZoneId.of(zone.id)
|
val zid = ZoneId.of(zone.id)
|
||||||
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||||
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).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 startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||||
|
|||||||
@@ -8,14 +8,9 @@ import android.content.pm.PackageManager
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.ContactsContract
|
import android.provider.ContactsContract
|
||||||
import android.util.Patterns
|
import android.util.Patterns
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -63,7 +58,6 @@ import androidx.compose.material3.DropdownMenu
|
|||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -121,6 +115,9 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
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.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
@@ -135,6 +132,8 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
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.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
@@ -202,8 +201,6 @@ fun EventEditScreen(
|
|||||||
viewModel.reset()
|
viewModel.reset()
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
BackHandler(onBack = close)
|
|
||||||
|
|
||||||
// The event vanished between the detail screen and the edit tap — fall
|
// The event vanished between the detail screen and the edit tap — fall
|
||||||
// back to the detail screen, which shows its own failure state.
|
// back to the detail screen, which shows its own failure state.
|
||||||
LaunchedEffect(loadFailed) {
|
LaunchedEffect(loadFailed) {
|
||||||
@@ -253,6 +250,7 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.predictiveBack(onBack = close),
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -410,7 +408,6 @@ private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime }
|
|||||||
* spring open with the expressive spatial spec instead of popping in.
|
* spring open with the expressive spatial spec instead of popping in.
|
||||||
* (Initially-visible sections render without animating.)
|
* (Initially-visible sections render without animating.)
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OptionalFormSection(
|
private fun OptionalFormSection(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
@@ -418,10 +415,8 @@ private fun OptionalFormSection(
|
|||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
|
enter = calendarExpandEnter(),
|
||||||
fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()),
|
exit = calendarCollapseExit(),
|
||||||
exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) +
|
|
||||||
fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()),
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
||||||
}
|
}
|
||||||
@@ -1898,8 +1893,9 @@ private fun ScheduleRow(
|
|||||||
val dateFormat = remember(locale) {
|
val dateFormat = remember(locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
}
|
}
|
||||||
val timeFormat = remember(locale) {
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
val timeFormat = remember(locale, use24Hour) {
|
||||||
|
timeOfDayFormatter(use24Hour, locale)
|
||||||
}
|
}
|
||||||
// Tappable values read as links (primary), like the location on the
|
// Tappable values read as links (primary), like the location on the
|
||||||
// detail screen; errors flip them to the error colour.
|
// detail screen; errors flip them to the error colour.
|
||||||
|
|||||||
@@ -121,10 +121,18 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val allowColorOnUnsupported: Boolean,
|
val allowColorOnUnsupported: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Writable calendars — the only valid event targets. */
|
/**
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
* excluded, so you can't create into a calendar you've removed from the app;
|
||||||
.catch { emit(emptyList()) }
|
* 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. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ class FilterViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
prefs.hiddenCalendarIds,
|
prefs.hiddenCalendarIds,
|
||||||
) { calendars, hidden ->
|
prefs.disabledCalendarIds,
|
||||||
if (calendars.isEmpty()) {
|
) { 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)
|
FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
} else {
|
} else {
|
||||||
FilterUiState.Success(groupByAccount(calendars, hidden))
|
FilterUiState.Success(groupByAccount(enabled, hidden))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(FilterUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.imports
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -39,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +57,6 @@ fun ImportScreen(
|
|||||||
) {
|
) {
|
||||||
LaunchedEffect(uri) { viewModel.load(uri) }
|
LaunchedEffect(uri) { viewModel.load(uri) }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
BackHandler(onBack = onClose)
|
|
||||||
|
|
||||||
// A single event isn't shown here — it opens the create form for review.
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
LaunchedEffect(state) {
|
LaunchedEffect(state) {
|
||||||
@@ -65,7 +64,9 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.predictiveBack(onBack = onClose)
|
||||||
|
.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.import_title)) },
|
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.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsImporter
|
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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
@@ -56,6 +57,7 @@ sealed interface ImportUiState {
|
|||||||
class ImportViewModel @Inject constructor(
|
class ImportViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val importer: IcsImporter,
|
private val importer: IcsImporter,
|
||||||
|
private val prefs: CalendarPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -79,11 +81,17 @@ class ImportViewModel @Inject constructor(
|
|||||||
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
)
|
)
|
||||||
else -> ImportUiState.Many(
|
else -> {
|
||||||
events = parsed.events,
|
// A disabled calendar is removed from the app, so it can't be
|
||||||
warnings = parsed.warnings,
|
// an import target — exclude it alongside the read-only ones.
|
||||||
calendars = repository.calendars().first().filter { it.canModifyContents },
|
val disabled = prefs.disabledCalendarIds.first()
|
||||||
)
|
ImportUiState.Many(
|
||||||
|
events = parsed.events,
|
||||||
|
warnings = parsed.warnings,
|
||||||
|
calendars = repository.calendars().first()
|
||||||
|
.filter { it.canModifyContents && it.id !in disabled },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
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.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
@@ -217,6 +219,8 @@ private fun MonthContent(
|
|||||||
val threshold = with(density) { 6.dp.toPx() }
|
val threshold = with(density) { 6.dp.toPx() }
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = Modifier.pointerInput(Unit) {
|
||||||
detectHorizontalDragGestures(
|
detectHorizontalDragGestures(
|
||||||
@@ -243,7 +247,7 @@ private fun MonthContent(
|
|||||||
MonthUiState.Loading -> "loading"
|
MonthUiState.Loading -> "loading"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
label = "month-transition",
|
label = "month-transition",
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.search
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -49,10 +48,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
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.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
@@ -88,10 +91,8 @@ fun SearchScreen(
|
|||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
keyboard?.show()
|
keyboard?.show()
|
||||||
}
|
}
|
||||||
BackHandler(onBack = onBack)
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier.predictiveBack(onBack = onBack),
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -174,6 +175,7 @@ private fun SearchResults(
|
|||||||
SearchResultRow(
|
SearchResultRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, events.size),
|
position = positionOf(index, events.size),
|
||||||
|
modifier = calendarAnimateItem(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -184,10 +186,12 @@ private fun SearchResults(
|
|||||||
private fun SearchResultRow(
|
private fun SearchResultRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
modifier = modifier,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
summary = searchSummary(event),
|
summary = searchSummary(event),
|
||||||
position = position,
|
position = position,
|
||||||
@@ -215,12 +219,11 @@ private fun searchSummary(event: EventInstance): String {
|
|||||||
val dateText = remember(locale) {
|
val dateText = remember(locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
}.format(start)
|
}.format(start)
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val timeText = if (event.isAllDay) {
|
val timeText = if (event.isAllDay) {
|
||||||
stringResource(R.string.event_detail_all_day)
|
stringResource(R.string.event_detail_all_day)
|
||||||
} else {
|
} else {
|
||||||
remember(locale) {
|
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
|
||||||
}.format(start)
|
|
||||||
}
|
}
|
||||||
val base = "$dateText · $timeText"
|
val base = "$dateText · $timeText"
|
||||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||||
|
|||||||
@@ -42,11 +42,13 @@ import androidx.compose.material.icons.filled.CalendarMonth
|
|||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
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.Gavel
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
@@ -82,14 +84,19 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
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.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
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.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
@@ -99,7 +106,10 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
@@ -288,6 +298,7 @@ private fun AboutCard() {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sourceUrl = stringResource(R.string.about_source_url)
|
val sourceUrl = stringResource(R.string.about_source_url)
|
||||||
val licenseUrl = stringResource(R.string.about_license_url)
|
val licenseUrl = stringResource(R.string.about_license_url)
|
||||||
|
val supportUrl = stringResource(R.string.about_support_url)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
@@ -346,6 +357,19 @@ private fun AboutCard() {
|
|||||||
Text(stringResource(R.string.settings_license))
|
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,6 +428,8 @@ private fun AppearanceScreen(
|
|||||||
) {
|
) {
|
||||||
var showTheme by remember { mutableStateOf(false) }
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
var showWeekStart by remember { mutableStateOf(false) }
|
var showWeekStart by remember { mutableStateOf(false) }
|
||||||
|
var showTimeFormat by remember { mutableStateOf(false) }
|
||||||
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
@@ -415,6 +441,12 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showTheme = true },
|
onClick = { showTheme = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
summary = stringResource(state.defaultView.labelRes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showDefaultView = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_dynamic_color),
|
title = stringResource(R.string.settings_dynamic_color),
|
||||||
summary = if (state.dynamicColorAvailable) {
|
summary = if (state.dynamicColorAvailable) {
|
||||||
@@ -439,9 +471,27 @@ private fun AppearanceScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
summary = weekStartLabel(state.weekStart),
|
summary = weekStartLabel(state.weekStart),
|
||||||
position = Position.Bottom,
|
position = Position.Middle,
|
||||||
onClick = { showWeekStart = true },
|
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.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.showHourLines,
|
||||||
|
onCheckedChange = viewModel::setShowHourLines,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
@@ -457,13 +507,33 @@ private fun AppearanceScreen(
|
|||||||
if (showWeekStart) {
|
if (showWeekStart) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
options = WeekStartPref.entries,
|
options = WEEK_START_OPTIONS,
|
||||||
selected = state.weekStart,
|
selected = state.weekStart,
|
||||||
label = { weekStartLabel(it) },
|
label = { weekStartLabel(it) },
|
||||||
onSelect = viewModel::setWeekStart,
|
onSelect = viewModel::setWeekStart,
|
||||||
onDismiss = { showWeekStart = false },
|
onDismiss = { showWeekStart = 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 (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
|
@Composable
|
||||||
@@ -655,7 +725,11 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
AnimatedVisibility(visible = expanded) {
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
Column {
|
Column {
|
||||||
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -952,12 +1026,25 @@ 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
|
@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) {
|
when (pref) {
|
||||||
WeekStartPref.AUTO -> R.string.settings_week_start_auto
|
TimeFormatPref.AUTO -> R.string.settings_time_format_auto
|
||||||
WeekStartPref.MONDAY -> R.string.settings_week_start_monday
|
TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
|
||||||
WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday
|
TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.calendula.ui.settings
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
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.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -16,7 +18,13 @@ data class SettingsUiState(
|
|||||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
val dynamicColorAvailable: 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,
|
||||||
|
/** 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"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
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.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -72,8 +74,13 @@ class SettingsViewModel @Inject constructor(
|
|||||||
) { overrides, allDayOverrides, calendars ->
|
) { overrides, allDayOverrides, calendars ->
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||||
},
|
},
|
||||||
) { base, defaults, overrides ->
|
combine(prefs.defaultView, prefs.timeFormat) { view, timeFormat -> view to timeFormat },
|
||||||
|
prefs.showHourLines,
|
||||||
|
) { base, defaults, overrides, viewAndTimeFormat, showHourLines ->
|
||||||
base.copy(
|
base.copy(
|
||||||
|
defaultView = viewAndTimeFormat.first,
|
||||||
|
timeFormat = viewAndTimeFormat.second,
|
||||||
|
showHourLines = showHourLines,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -115,6 +122,18 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTimeFormat(pref: TimeFormatPref) {
|
||||||
|
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowHourLines(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultView(view: CalendarView) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,14 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
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.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.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
@@ -247,6 +254,8 @@ private fun WeekContent(
|
|||||||
val threshold = with(density) { 24.dp.toPx() }
|
val threshold = with(density) { 24.dp.toPx() }
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
|
||||||
// Hoisted above the per-week AnimatedContent so the vertical scroll position
|
// 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
|
// survives week-to-week swipes (e.g. 18:00 stays centred). We only centre on
|
||||||
@@ -300,7 +309,7 @@ private fun WeekContent(
|
|||||||
WeekUiState.Loading -> "loading"
|
WeekUiState.Loading -> "loading"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec) },
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
label = "week-transition",
|
label = "week-transition",
|
||||||
) { s ->
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
@@ -568,6 +577,8 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||||
@@ -591,7 +602,7 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "%02d".format(h),
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -646,6 +657,8 @@ private fun DayColumnCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
|
val showHourLines = LocalShowHourLines.current
|
||||||
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular columns — the soft corners come from the outer
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -658,6 +671,9 @@ private fun DayColumnCard(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.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
|
// Tap an empty slot to create an event there; taps on event
|
||||||
// blocks are consumed by their own handler first. Snaps to hour.
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
@@ -700,7 +716,10 @@ private fun EventBlock(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
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
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -769,8 +788,8 @@ private fun WeekLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun minToHm(min: Int): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.Manifest
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
@@ -46,7 +47,12 @@ internal fun Context.hasCalendarPermission(): Boolean =
|
|||||||
sealed interface AgendaWidgetData {
|
sealed interface AgendaWidgetData {
|
||||||
/** Calendar permission not granted — the widget can't read events. */
|
/** Calendar permission not granted — the widget can't read events. */
|
||||||
data object NeedsPermission : AgendaWidgetData
|
data object NeedsPermission : AgendaWidgetData
|
||||||
data class Ready(val today: LocalDate, val days: List<AgendaDay>) : AgendaWidgetData
|
data class Ready(
|
||||||
|
val today: LocalDate,
|
||||||
|
val days: List<AgendaDay>,
|
||||||
|
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||||
|
val is24Hour: Boolean,
|
||||||
|
) : AgendaWidgetData
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,9 +97,15 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
val repo = widgetEntryPoint().calendarRepository()
|
val ep = widgetEntryPoint()
|
||||||
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
val instances = ep.calendarRepository().instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
||||||
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
val is24Hour = ep.settingsPrefs().timeFormat.first()
|
||||||
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||||
|
return AgendaWidgetData.Ready(
|
||||||
|
today = anchor,
|
||||||
|
days = groupAgendaDays(anchor, instances, zone),
|
||||||
|
is24Hour = is24Hour,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import androidx.glance.text.TextStyle
|
|||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
@@ -114,7 +116,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||||
is AgendaRow.Event -> EventRow(row.event, dark)
|
is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,12 +187,16 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
.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
|
@Composable
|
||||||
private fun EventRow(event: EventInstance, dark: Boolean) {
|
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
Row(
|
Row(
|
||||||
@@ -199,11 +205,12 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
|
|||||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
.padding(horizontal = 4.dp, vertical = 4.dp)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.eventDetailIntent(
|
MainActivity.openEventIntent(
|
||||||
context = context,
|
context = context,
|
||||||
eventId = event.eventId,
|
eventId = event.eventId,
|
||||||
beginMillis = event.start.toEpochMilliseconds(),
|
beginMillis = event.start.toEpochMilliseconds(),
|
||||||
endMillis = event.end.toEpochMilliseconds(),
|
endMillis = event.end.toEpochMilliseconds(),
|
||||||
|
source = CalendarView.Agenda,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -224,7 +231,7 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
|
|||||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event),
|
text = eventTimeSummary(context, event, is24Hour),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||||
)
|
)
|
||||||
@@ -263,17 +270,17 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
|||||||
return if (relative != null) "$relative · $formatted" else formatted
|
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) {
|
val time = if (event.isAllDay) {
|
||||||
context.getString(R.string.event_detail_all_day)
|
context.getString(R.string.event_detail_all_day)
|
||||||
} else {
|
} else {
|
||||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
||||||
}
|
}
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
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
|
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.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
@@ -209,7 +210,9 @@ private fun MonthHeader(label: String) {
|
|||||||
resId = R.drawable.ic_widget_today,
|
resId = R.drawable.ic_widget_today,
|
||||||
contentDescription = context.getString(R.string.widget_today),
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
onClick = GlanceModifier.clickable(
|
onClick = GlanceModifier.clickable(
|
||||||
actionStartActivity(MainActivity.openDateIntent(context, today(systemZone()))),
|
actionStartActivity(
|
||||||
|
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
HeaderIcon(
|
HeaderIcon(
|
||||||
@@ -300,8 +303,16 @@ private fun WeekRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Box(
|
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,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -353,7 +364,24 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||||
val context = LocalContext.current
|
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(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
||||||
<string name="settings_week_start">Wochenstart</string>
|
<string name="settings_week_start">Wochenstart</string>
|
||||||
<string name="settings_week_start_auto">Automatisch</string>
|
<string name="settings_week_start_auto">Automatisch</string>
|
||||||
<string name="settings_week_start_monday">Montag</string>
|
<string name="settings_time_format">Zeitformat</string>
|
||||||
<string name="settings_week_start_sunday">Sonntag</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_section_event_form">Termin-Formular</string>
|
<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_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||||
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||||
@@ -315,6 +319,7 @@
|
|||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
||||||
<string name="settings_about_source">Quellcode</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_version">Version %1$s</string>
|
||||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||||
<string name="settings_report_problem">Problem melden</string>
|
<string name="settings_report_problem">Problem melden</string>
|
||||||
|
|||||||
@@ -264,12 +264,17 @@
|
|||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
<string name="settings_theme_light">Light</string>
|
<string name="settings_theme_light">Light</string>
|
||||||
<string name="settings_theme_dark">Dark</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">Dynamic colour</string>
|
||||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</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">Week starts on</string>
|
||||||
<string name="settings_week_start_auto">Automatic</string>
|
<string name="settings_week_start_auto">Automatic</string>
|
||||||
<string name="settings_week_start_monday">Monday</string>
|
<string name="settings_time_format">Time format</string>
|
||||||
<string name="settings_week_start_sunday">Sunday</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_section_event_form">New event form</string>
|
<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_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||||
@@ -299,7 +304,7 @@
|
|||||||
<string name="settings_language">App language</string>
|
<string name="settings_language">App language</string>
|
||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- 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_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
<string name="settings_section_about">About</string>
|
<string name="settings_section_about">About</string>
|
||||||
@@ -307,6 +312,7 @@
|
|||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||||
<string name="settings_about_source">Source</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_version">Version %1$s</string>
|
||||||
<string name="settings_about_logo_desc">Calendula app icon</string>
|
<string name="settings_about_logo_desc">Calendula app icon</string>
|
||||||
<string name="settings_report_problem">Report a problem</string>
|
<string name="settings_report_problem">Report a problem</string>
|
||||||
@@ -317,6 +323,8 @@
|
|||||||
<string name="calendars_local_header">Your calendars</string>
|
<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_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_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_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_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</string>
|
||||||
@@ -377,6 +385,7 @@
|
|||||||
|
|
||||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
<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>
|
||||||
|
|
||||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||||
Gitea issue (the app sends nothing automatically). -->
|
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
|
@Test
|
||||||
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
||||||
|
|||||||
@@ -50,4 +50,34 @@ class CalendarPrefsTest {
|
|||||||
}
|
}
|
||||||
assertThat(prefs.hiddenCalendarIds.first()).isEqualTo(setOf(1L, 3L))
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class SettingsPrefsTest {
|
|||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
||||||
assertThat(prefs.dynamicColor.first()).isTrue()
|
assertThat(prefs.dynamicColor.first()).isTrue()
|
||||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO)
|
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -43,10 +43,50 @@ class SettingsPrefsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
prefs.setWeekStart(WeekStartPref.SUNDAY)
|
prefs.setWeekStart(WeekStartPref.Day(DayOfWeek.SATURDAY))
|
||||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY)
|
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
|
@Test
|
||||||
@@ -252,13 +292,15 @@ class SettingsPrefsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
|
||||||
assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY)
|
.isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
assertThat(WeekStartPref.Day(DayOfWeek.SATURDAY).resolveFirstDay(Locale.GERMANY))
|
||||||
|
.isEqualTo(DayOfWeek.SATURDAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `auto week start follows the locale convention`() {
|
fun `auto week start follows the locale convention`() {
|
||||||
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,24 @@ class ReminderTimeTextTest {
|
|||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
locale = Locale.GERMANY,
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("09:30 – 10:00")
|
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
|
@Test
|
||||||
fun `timed event crossing midnight includes both dates`() {
|
fun `timed event crossing midnight includes both dates`() {
|
||||||
val text = reminderTimeText(
|
val text = reminderTimeText(
|
||||||
@@ -38,6 +52,7 @@ class ReminderTimeTextTest {
|
|||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
locale = Locale.GERMANY,
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).contains("11.06.2026")
|
assertThat(text).contains("11.06.2026")
|
||||||
assertThat(text).contains("12.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.
|
// 02:00 in Berlin — naive local reading would shift the day.
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
locale = Locale.GERMANY,
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026")
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
}
|
}
|
||||||
@@ -66,6 +82,7 @@ class ReminderTimeTextTest {
|
|||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
locale = Locale.GERMANY,
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
||||||
}
|
}
|
||||||
@@ -79,6 +96,7 @@ class ReminderTimeTextTest {
|
|||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
locale = Locale.GERMANY,
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026")
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
||||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
||||||
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
|
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
|
||||||
|
Donate: https://ko-fi.com/jeanlucmakiola
|
||||||
|
|
||||||
AutoName: Calendula
|
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.
|
||||||
21
fastlane/metadata/android/en-US/changelogs/21100.txt
Normal file
21
fastlane/metadata/android/en-US/changelogs/21100.txt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
### 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. New **Agenda range** and **Agenda widget
|
||||||
|
range** settings (Settings → Appearance) let the agenda screen and its 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
|
||||||
|
preference. Thanks to @zmaherdev for the suggestion ([#4]).
|
||||||
|
|
||||||
@@ -8,3 +8,4 @@ Categories:
|
|||||||
|
|
||||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
||||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
||||||
|
Donate: https://ko-fi.com/jeanlucmakiola
|
||||||
|
|||||||
Reference in New Issue
Block a user