Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b62f097392 | |||
| 210ddff8d8 | |||
| e194da3766 |
@@ -107,43 +107,209 @@ Deliberately deferred (add only if needed):
|
|||||||
- Snooze / dismiss notification actions (Etar has them)
|
- Snooze / dismiss notification actions (Etar has them)
|
||||||
- Battery-optimization exemption prompt for delivery reliability
|
- Battery-optimization exemption prompt for delivery reliability
|
||||||
|
|
||||||
## v3.0 — Power-User Features
|
## v2.1 — Month event grid + drawer view tabs (shipped 2026-06-15)
|
||||||
|
|
||||||
- Home-screen widget
|
- Month grid shows real events as continuous multi-day bars (not just dots)
|
||||||
- Full-text search
|
- View section in the navigation drawer to switch Month / Week / Day
|
||||||
- Tablet / foldable layouts
|
- Fix: text cursor no longer jumps in event text fields
|
||||||
- Optional: ICS file import (drag-and-drop)
|
|
||||||
- Optional: move event to another calendar (copy+delete model with a
|
|
||||||
consequences warning — deferred from v2.0, see above)
|
|
||||||
|
|
||||||
Order is indicative — community feedback after V1 may re-prioritize.
|
## v2.2 — Tap-to-create + local calendar management (shipped 2026-06-16)
|
||||||
|
|
||||||
## Idea backlog — Daily-driver polish (captured 2026-06-11, all approved as ideas, unscheduled)
|
- Tap an empty slot in day/week → create form prefilled with that day + the
|
||||||
|
tapped hour (snapped to the hour, 1 h long)
|
||||||
|
- Local (device-only) calendar management in a full-screen editor from
|
||||||
|
Settings → Calendars: create / rename / recolor / delete, with name,
|
||||||
|
pastel-previewed colour, and description (stored in `CAL_SYNC1`)
|
||||||
|
- Synced calendars listed read-only, grouped by account, each with a
|
||||||
|
per-account "manage in source app" deep-link (resolved from the account's
|
||||||
|
authenticator — DAVx5/ICSx5/…) + an add-account shortcut
|
||||||
|
- Shared `InlineTextField` extracted to `ui.common` (event form + calendar
|
||||||
|
editor share one input style)
|
||||||
|
|
||||||
Interaction:
|
## v2.3 — Material 3 grouped-list redesign (shipped 2026-06-16)
|
||||||
- Tap/long-press an empty slot in day/week → create form prefilled with that time
|
|
||||||
- Drag & drop rescheduling in day/week (recurring drops reuse the scope dialog) — big-ticket, own slice
|
A structural + visual pass adopting one shared blueprint (modelled on the ReFra
|
||||||
- Agenda view (fourth view: upcoming events grouped by day; natural widget data source)
|
gallery app) across Settings, the calendar manager and the navigation drawer.
|
||||||
|
|
||||||
|
- Shared `ui/common/GroupedList.kt`: `CollapsingScaffold` (a `LargeTopAppBar`
|
||||||
|
whose title collapses on scroll) + `GroupedRow` (Position-based corner
|
||||||
|
grouping, press-animated corners, `selected` + `minHeight` knobs).
|
||||||
|
- Settings: category hub with About card on top and sliding sub-pages
|
||||||
|
(Appearance / New event form / Notifications); theme/week-start/language
|
||||||
|
pickers moved from `DropdownMenu` to OptionCard dialogs; token-based icon
|
||||||
|
chips; `ic_gitea.xml` for the About "Source" button.
|
||||||
|
- Calendar manager + drawer restyled to match; shared `CalendarColorChip`;
|
||||||
|
drawer scrolls as one with the active view highlighted.
|
||||||
|
- Cards use `surfaceContainerHigh` for readable contrast.
|
||||||
|
- Donate button on the About card deferred (target TBD).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Backlog (theme-based, post-v2.1)
|
||||||
|
|
||||||
|
The old v3.0 / "daily-driver polish" / "Locations & People" lists are
|
||||||
|
consolidated here by theme. Within a group, **(in progress)** /
|
||||||
|
**(next)** mark what is being or about to be worked; everything else is an
|
||||||
|
approved-but-unscheduled idea unless tagged **(idea)** /
|
||||||
|
**(go/no-go)** / **(rejected)**. Order across groups is not a commitment.
|
||||||
|
|
||||||
|
## Near-term sequence (ranked, 2026-06-16)
|
||||||
|
|
||||||
|
The theme groups below are the full menu; this is the committed *order* for
|
||||||
|
the next stretch. Ranking favours finishing the current create/edit + calendar
|
||||||
|
arc before opening new fronts, then cheap-relative-to-value items and ones that
|
||||||
|
unblock a later item. Order is a plan, not a contract — revisit after each lands.
|
||||||
|
|
||||||
|
**Tier 1 — finish the current arc (create/edit + calendars)**
|
||||||
|
1. Tap-to-create in day/week *(shipped v2.2.0)* — prefilled create from an empty slot
|
||||||
|
2. Local calendar management + "manage in source app" deep-links *(shipped v2.2.0)*
|
||||||
|
3. ~~Settings redesign & restructure~~ *(shipped v2.3.0 — grew into the full
|
||||||
|
grouped-list blueprint across Settings + calendars + drawer; see "v2.3"
|
||||||
|
above)*
|
||||||
|
4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write
|
||||||
|
`EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw
|
||||||
|
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars
|
||||||
|
5. **Duplicate event** *(next)* — detail action → prefilled create form; near-free on the tap-to-create prefill infra
|
||||||
|
|
||||||
|
(Tier 2+ numbering below shifts accordingly; ranking unchanged.)
|
||||||
|
|
||||||
|
### Settings redesign & restructure *(shipped v2.3.0)*
|
||||||
|
|
||||||
|
The original scope below is kept as a record; the implementation expanded from a
|
||||||
|
sub-screen restructure into the shared grouped-list blueprint (see "v2.3" above).
|
||||||
|
|
||||||
|
The settings screen has grown into a flat vertical scroll of divider-separated
|
||||||
|
sections (Appearance, Event form, Notifications, Calendars, Language, About) and
|
||||||
|
will keep accreting rows (per-event-color defaults, default reminder, more
|
||||||
|
calendar entries are all queued). It needs structure before it gets unwieldy.
|
||||||
|
|
||||||
|
**Decided (2026-06-16): sub-screens**, not flat-but-carded. The top level
|
||||||
|
becomes a category list; each category opens its own destination. More
|
||||||
|
M3-idiomatic for a settings surface that will keep growing, and it mirrors the
|
||||||
|
existing Calendars row, which already navigates out to its own screen.
|
||||||
|
|
||||||
|
Structure — top-level settings list → category destinations:
|
||||||
|
- **Appearance** → theme, dynamic colour, week start
|
||||||
|
- **Event form** → the 6 default-field toggles + the hint text
|
||||||
|
- **Notifications** → reminders toggle (POST_NOTIFICATIONS flow stays)
|
||||||
|
- **Calendars** → already its own screen (`CalendarsScreen`); just becomes a
|
||||||
|
peer category row, no change to that screen
|
||||||
|
- **Language** → single control; keep as a top-level row that opens an
|
||||||
|
OptionCard directly (a whole sub-screen for one choice is overkill)
|
||||||
|
- **About** → kept inline on the top-level list as a card (read-only info,
|
||||||
|
not worth a navigation hop). Card layout, top → bottom:
|
||||||
|
- **Identity** — app logo + name "Calendula", with "by Jean-Luc Makiola"
|
||||||
|
as a subtitle beneath the name
|
||||||
|
- **Action buttons** (small, button-styled, sit in a row):
|
||||||
|
- **Source** — Gitea logo, opens the repo (`about_source_url`)
|
||||||
|
- **License** — opens the LICENSE file on Gitea
|
||||||
|
- **Donate** *(tentative)* — sits next to Source; target TBD (decide
|
||||||
|
before building: Liberapay / Ko-fi / Gitea sponsor / etc.)
|
||||||
|
- **Version** — small version number at the bottom of the card
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- **Navigation** — add the settings sub-screen destinations alongside the
|
||||||
|
existing settings/calendars routes in `CalendarHost`; back pops to the
|
||||||
|
settings list (mind the existing `BackHandler` that guards against falling
|
||||||
|
through to the activity).
|
||||||
|
- **Fix the dialog-pattern violation** — theme, week-start and language use
|
||||||
|
`DropdownMenu`; the project default is the full-width tonal OptionCard modal
|
||||||
|
(radio/dropdown/text-list dialogs are banned, see
|
||||||
|
`option-card-modal-style-default`). Migrate these selectors to OptionCard.
|
||||||
|
- **Visual pass** — top-level category rows with leading icons; consistent
|
||||||
|
spacing and row affordances aligned with the event-form card design system.
|
||||||
|
|
||||||
|
Out of scope (no new settings *features* here) — this is a structure + style
|
||||||
|
pass on the existing controls; new toggles ride in with their own features.
|
||||||
|
|
||||||
|
**Tier 2 — navigation & daily-driver completeness**
|
||||||
|
5. Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap
|
||||||
|
6. Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget
|
||||||
|
|
||||||
|
**Tier 3 — platform reach (depends on Tier 2)**
|
||||||
|
7. Home-screen widget — built on the agenda data source from #6
|
||||||
|
8. App shortcuts (launcher long-press → New event); cheap, optional quick-settings tile
|
||||||
|
|
||||||
|
**Tier 4 — interop & bigger-ticket**
|
||||||
|
9. Share event as .ics + receive/open .ics into a prefilled create form
|
||||||
|
10. Default reminder applied to new events; then snooze/dismiss notification actions
|
||||||
|
11. Drag & drop rescheduling in day/week — big-ticket, own slice (recurring drops reuse the scope dialog)
|
||||||
|
|
||||||
|
**Gated — explicit go/no-go before any work (mostly INTERNET-permission calls)**
|
||||||
|
- Remote calendar create/edit (re-implements DAVx5; INTERNET + credential storage)
|
||||||
|
- Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET
|
||||||
|
- Move event to another calendar — sync-adapter minefield (copy+delete model)
|
||||||
|
|
||||||
|
**Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
|
||||||
|
full-text search, ICS file import. Pulled in opportunistically, not sequenced.
|
||||||
|
|
||||||
|
Debatable calls worth a second look: widget (#7) vs .ics interop (#9) ordering;
|
||||||
|
whether drag-drop (#11) jumps ahead given its daily-driver impact.
|
||||||
|
|
||||||
|
## Navigation & views
|
||||||
|
|
||||||
|
- ~~Tap an empty slot in day/week → create form prefilled with that
|
||||||
|
date+time, snapped to the hour~~ **shipped v2.2.0** (long-press variant
|
||||||
|
not added — single tap covers it)
|
||||||
|
- Agenda view (fourth view: upcoming events grouped by day; also the
|
||||||
|
natural data source for a future widget)
|
||||||
|
- Jump to date — drawer date picker (un-cut from V1)
|
||||||
- Pinch-to-zoom time scale in day/week
|
- Pinch-to-zoom time scale in day/week
|
||||||
|
- Tablet / foldable layouts *(was v3.0)*
|
||||||
|
- Full-text search *(was v3.0)*
|
||||||
|
|
||||||
Reminders, round two:
|
## Event editing & creation
|
||||||
- Snooze + dismiss actions on the notification (snooze needs an exact-alarm/WorkManager decision)
|
|
||||||
|
- Drag & drop rescheduling in day/week (recurring drops reuse the scope
|
||||||
|
dialog) — big-ticket, own slice
|
||||||
|
- Duplicate event (detail action → prefilled create form)
|
||||||
|
- **Per-event color** (`Events.EVENT_COLOR`, OptionCard picker in the form)
|
||||||
|
*(next)* — chosen to follow the in-progress tap-to-create + calendar
|
||||||
|
management work: reuses the color-picker component and palette plumbing
|
||||||
|
being built for local calendar management, and finishes the create/edit
|
||||||
|
theme. `EVENT_COLOR` / `EVENT_COLOR_KEY` from the calendar's color list
|
||||||
|
(`Colors` table, `TYPE_EVENT`); falls back to the calendar color when unset.
|
||||||
|
|
||||||
|
## Calendars & accounts
|
||||||
|
|
||||||
|
- ~~Create / manage local (device-only) calendars~~ **shipped v2.2.0** —
|
||||||
|
name + color + description; rename / recolor / delete the calendars the app
|
||||||
|
owns. Inserted under `ACCOUNT_TYPE_LOCAL` as a sync adapter; description in
|
||||||
|
`CAL_SYNC1`. Full-screen "Calendars" editor reached from Settings.
|
||||||
|
- ~~Per-calendar "manage in source app" deep-link~~ **shipped v2.2.0** — for
|
||||||
|
synced calendars, open the app the calendar actually came from based on
|
||||||
|
its `ACCOUNT_TYPE` (DAVx5 `bitfire.at.davdroid`, Google `com.google`,
|
||||||
|
…); fall back to system account/sync settings. Plus an "add account"
|
||||||
|
entry into system Accounts. Honest boundary for remote calendars.
|
||||||
|
- **Remote calendar create/edit** *(go/no-go)* — creating a CalDAV
|
||||||
|
collection (`MKCALENDAR`) or a Google calendar means an in-app sync
|
||||||
|
client: **INTERNET permission, credential storage, the full server
|
||||||
|
round-trip** — i.e. re-implementing DAVx5. DAVx5 exposes no public
|
||||||
|
intent to delegate the create to it. Cosmetic local edits (color/name)
|
||||||
|
to an existing synced row are possible but don't propagate to the server
|
||||||
|
and may be overwritten on next sync — not promised. Same explicit
|
||||||
|
go/no-go gate as the OSM/INTERNET item below.
|
||||||
|
- Move event to another calendar (copy+delete model with a consequences
|
||||||
|
warning — deferred from v2.0; `CALENDAR_ID` is sync-adapter-owned) *(was v3.0)*
|
||||||
|
|
||||||
|
## Reminders, round two
|
||||||
|
|
||||||
|
- Snooze + dismiss actions on the notification (snooze needs an
|
||||||
|
exact-alarm / WorkManager decision)
|
||||||
- Settings default reminder applied to new events
|
- Settings default reminder applied to new events
|
||||||
|
|
||||||
Event niceties:
|
## Sharing & interop
|
||||||
- Duplicate event (detail action → prefilled create form)
|
|
||||||
- Per-event color (`Events.EVENT_COLOR`, OptionCard picker in the form)
|
|
||||||
- Share event as .ics + open/receive .ics into a prefilled create form (front-runs v3 ICS import)
|
|
||||||
|
|
||||||
Small delights:
|
- Share event as .ics + open/receive .ics into a prefilled create form
|
||||||
|
(front-runs the import below)
|
||||||
|
- ICS file import (drag-and-drop) *(was v3.0, optional)*
|
||||||
|
|
||||||
|
## Platform & launchers
|
||||||
|
|
||||||
|
- Home-screen widget *(was v3.0)*
|
||||||
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
|
- App shortcuts (launcher long-press → New event), maybe a quick-settings tile
|
||||||
- Jump to date (un-cut from V1 — drawer date picker)
|
|
||||||
|
|
||||||
Consciously rejected: travel time / weather / smart suggestions (network,
|
## Locations & People *(go/no-go, captured 2026-06-11)*
|
||||||
core-promise conflict), natural-language quick entry (high effort,
|
|
||||||
locale-fragile, prefilled form already covers fast entry).
|
|
||||||
|
|
||||||
## Idea backlog — Locations & People (captured 2026-06-11, undecided)
|
|
||||||
|
|
||||||
Beyond classic calendar-client scope; discussed, deliberately not planned
|
Beyond classic calendar-client scope; discussed, deliberately not planned
|
||||||
in detail yet:
|
in detail yet:
|
||||||
@@ -163,3 +329,10 @@ in detail yet:
|
|||||||
- **Attendee editing / invites from contacts** — own milestone; writing
|
- **Attendee editing / invites from contacts** — own milestone; writing
|
||||||
`Attendees` rows touches sync-adapter invitation behavior (Google vs
|
`Attendees` rows touches sync-adapter invitation behavior (Google vs
|
||||||
DAVx5 differ).
|
DAVx5 differ).
|
||||||
|
|
||||||
|
## Consciously rejected
|
||||||
|
|
||||||
|
- Travel time / weather / smart suggestions (network, core-promise conflict)
|
||||||
|
- Natural-language quick entry (high effort, locale-fragile; the prefilled
|
||||||
|
form already covers fast entry)
|
||||||
|
- Quick-add sheet (the prefilled full form already covers it — cut in v2.0)
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
# Calendula — Current State
|
# Calendula — Current State
|
||||||
|
|
||||||
*Last updated: 2026-06-11*
|
*Last updated: 2026-06-17*
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11.
|
**Milestone:** 2 (write support) **complete** — v2.0.0 shipped 2026-06-11;
|
||||||
**Phase:** between milestones. Next: v3.0 (power-user features) and the
|
v2.1.0 (month event grid, drawer view tabs, cursor fix) shipped 2026-06-15.
|
||||||
go/no-go on the "Locations & People" idea backlog (`ROADMAP.md`). Docs
|
**Phase:** post-2.1 backlog work. v2.2.0 (tap-to-create in day/week + local
|
||||||
pass done (ARCHITECTURE.md, README overhaul, planning docs refreshed).
|
calendar management) and v2.3.0 (Material 3 grouped-list redesign of Settings,
|
||||||
|
the calendar manager and the navigation drawer) both shipped 2026-06-16. The
|
||||||
|
backlog is now organised by theme in `ROADMAP.md`.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -71,10 +73,56 @@ pass done (ARCHITECTURE.md, README overhaul, planning docs refreshed).
|
|||||||
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
|
quick-add cut, calendar-switch → v3 backlog; F-Droid/README copy
|
||||||
refreshed, fastlane screenshots DE+EN captured on-device
|
refreshed, fastlane screenshots DE+EN captured on-device
|
||||||
|
|
||||||
|
- [x] v2.1 (shipped 2026-06-15) — month grid shows real events as
|
||||||
|
continuous multi-day bars; navigation-drawer View section
|
||||||
|
(Month/Week/Day); cursor-jump fix in event text fields
|
||||||
|
|
||||||
|
- [x] v2.2 (shipped 2026-06-16) — tap an empty slot in day/week to create
|
||||||
|
(prefilled with that day + tapped hour, snapped to the hour); local
|
||||||
|
calendar management in a full-screen editor from Settings →
|
||||||
|
Calendars: create/rename/recolor/delete device-only calendars
|
||||||
|
(`ACCOUNT_TYPE_LOCAL`, sync-adapter insert) with name, pastel-previewed
|
||||||
|
colour, and description (stored in `CAL_SYNC1`); synced calendars listed
|
||||||
|
read-only grouped by account with a per-account "manage in source app"
|
||||||
|
deep-link (resolved from the account's authenticator: DAVx5/ICSx5/…) and
|
||||||
|
an add-account shortcut. Shared `InlineTextField` extracted to `ui.common`
|
||||||
|
|
||||||
|
- [x] v2.3 settings/calendars/drawer redesign (shipped 2026-06-16) — adopted a
|
||||||
|
shared Material 3 grouped-list blueprint, modelled on the ReFra gallery app
|
||||||
|
and extracted to `ui/common/GroupedList.kt` (`CollapsingScaffold` with a
|
||||||
|
`LargeTopAppBar` exit-until-collapsed title; `GroupedRow` with Position-based
|
||||||
|
corner grouping, press-animated corners, `selected` + `minHeight` knobs).
|
||||||
|
- Settings: category hub (About card on top → version mark at the foot) with
|
||||||
|
sliding sub-pages (Appearance / New event form / Notifications); token-
|
||||||
|
based icon chips; theme/week-start/language pickers migrated from
|
||||||
|
`DropdownMenu` to OptionCard dialogs. New `ic_gitea.xml` (Simple Icons,
|
||||||
|
verbatim path) for the About "Source" button; en+de strings.
|
||||||
|
- Calendar manager: same collapsing scaffold + grouped rows; shared
|
||||||
|
`CalendarColorChip` (neutral chip, pastelised calendar glyph).
|
||||||
|
- Navigation drawer: branded header, grouped View switcher (active view
|
||||||
|
highlighted via `secondaryContainer`), the filter list restyled to
|
||||||
|
grouped rows with a trailing checkbox; the whole drawer scrolls as one.
|
||||||
|
- Cards use `surfaceContainerHigh` for readable contrast against `surface`.
|
||||||
|
- Donate button on the About card deferred (target still TBD).
|
||||||
|
|
||||||
|
- [x] v2.4 per-event color (shipped 2026-06-17) — an optional "Color" field in
|
||||||
|
the event form. Read/render already resolved `EVENT_COLOR` with a calendar
|
||||||
|
fallback; this adds the write side and the picker. Palette-backed calendars
|
||||||
|
(Google, some CalDAV) pick from the account's `Colors` (`TYPE_EVENT`) and
|
||||||
|
write `EVENT_COLOR_KEY` so the color round-trips through sync; local
|
||||||
|
calendars write a raw `EVENT_COLOR` from the shared `CALENDAR_COLOR_PALETTE`
|
||||||
|
(extracted with the swatch row to `ui/common/ColorSwatchRow.kt`). Switching
|
||||||
|
calendars resets the choice (a key is account-scoped). A settings toggle
|
||||||
|
("Allow colors on unsupported calendars", off by default) extends the raw
|
||||||
|
path to synced calendars with no palette, with an honest "may not survive
|
||||||
|
sync" warning on the picker and in Settings. Color writes flow through
|
||||||
|
insert / dirty-checked update / occurrence-exception; mapper + form tests.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. Decide the "Locations & People" go/no-go (INTERNET permission question)
|
1. Monitor the F-Droid build/publish for the v2.4.0 tag
|
||||||
— see `ROADMAP.md` idea backlog
|
2. Decide the "Locations & People" and "remote calendar create/edit"
|
||||||
2. v3.0 scoping: widget, full-text search, tablet layouts, ICS import,
|
go/no-go calls (both hinge on the INTERNET permission) — see `ROADMAP.md`
|
||||||
calendar-move
|
3. **Duplicate event** and **jump-to-date** are the cheap follow-ups; then
|
||||||
3. Monitor the F-Droid build/publish for the v1.4.0 / v2.0.0 tags
|
agenda view (strategic, backs a future widget). Full ranked sequence in
|
||||||
|
`ROADMAP.md` → "Near-term sequence".
|
||||||
|
|||||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.4.0] — 2026-06-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Per-event colors: give a single event its own color, instead of always
|
||||||
|
inheriting its calendar's. Add the new "Color" field from "More fields" in
|
||||||
|
the event form. On calendars that publish their own color set — such as
|
||||||
|
Google — you pick from that calendar's palette, so the color is stored
|
||||||
|
with the event and shows correctly on every synced device. On local
|
||||||
|
calendars you pick from Calendula's palette. "Reset" returns an event to
|
||||||
|
its calendar's color
|
||||||
|
- A new "Allow colors on unsupported calendars" setting (New event form,
|
||||||
|
off by default) extends per-event colors to calendars that publish no
|
||||||
|
color set of their own (some CalDAV). Such a color is kept on the device
|
||||||
|
and may be dropped or overwritten on that calendar's next sync — a
|
||||||
|
limitation of those calendars, called out plainly in the setting and on
|
||||||
|
the color picker
|
||||||
|
|
||||||
|
## [2.3.0] — 2026-06-16
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Redesigned Settings around the Material 3 grouped-list pattern: a large
|
||||||
|
title that collapses into the toolbar as you scroll, category cards on the
|
||||||
|
main screen, and dedicated sub-pages for Appearance, the new-event form, and
|
||||||
|
Notifications. The theme, week-start and language pickers now use the app's
|
||||||
|
standard option-card dialogs instead of dropdown menus
|
||||||
|
- About moved to the top of Settings as a card — app icon, author, and quick
|
||||||
|
links to the source code and licence — with the version shown plainly at the
|
||||||
|
foot of the list
|
||||||
|
- The Calendars screen now uses the same grouped-card layout and collapsing
|
||||||
|
title, and each calendar shows a soft pastel-tinted calendar glyph rather
|
||||||
|
than a plain colour swatch
|
||||||
|
- Redesigned the navigation drawer to match: a branded header, the
|
||||||
|
Month / Week / Day switch and your calendars as grouped cards (with the
|
||||||
|
active view highlighted), and the whole drawer now scrolls as one
|
||||||
|
|
||||||
|
## [2.2.0] — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Tap an empty slot in the day or week view to create an event there: the
|
||||||
|
create form opens prefilled with that day and the tapped hour (snapped to
|
||||||
|
the hour, one hour long). Tapping an existing event still opens it
|
||||||
|
- Local calendars: create and manage device-only calendars that live
|
||||||
|
entirely on this phone — no account, no sync — from a new "Calendars"
|
||||||
|
screen in Settings. Give each a name, a colour, and an optional
|
||||||
|
description; rename, recolour, or delete them later. Useful when you want
|
||||||
|
a calendar without setting up an account
|
||||||
|
- The Calendars screen also lists your synced calendars (DAVx5, ICSx5, …)
|
||||||
|
grouped by account, each with a "Manage" button that opens the app the
|
||||||
|
calendar actually comes from, plus an "Add account" shortcut to the
|
||||||
|
system account settings. Calendula never touches a synced calendar's
|
||||||
|
server itself — that stays with its own app
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Colour swatches in the calendar editor now preview the soft, pastel tone
|
||||||
|
a calendar is actually drawn with, instead of a bright raw colour
|
||||||
|
- The calendar editor reuses the event form's field and button styling for
|
||||||
|
a consistent look
|
||||||
|
|
||||||
## [2.1.0] — 2026-06-15
|
## [2.1.0] — 2026-06-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
|
||||||
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
|
||||||
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
// default; keep them matching the latest released tag. See docs/RELEASING.md.
|
||||||
versionCode = 20100
|
versionCode = 20400
|
||||||
versionName = "2.1.0"
|
versionName = "2.4.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,18 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
|
intent makes launchable apps visible so we can launch whichever app owns a
|
||||||
|
calendar account's authenticator. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".CalendulaApp"
|
android:name=".CalendulaApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.ContentValues
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
@@ -13,6 +14,7 @@ import android.util.Log
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -36,6 +38,28 @@ interface CalendarDataSource {
|
|||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
fun eventDetail(eventId: Long): EventDetail?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event-colour palette the calendar's account publishes
|
||||||
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
||||||
|
* account exposes no palette (most local calendars, some CalDAV) — the
|
||||||
|
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
||||||
|
* which a synced calendar may drop on its next sync.
|
||||||
|
*/
|
||||||
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
|
||||||
|
* returns its `Calendars._ID`. Inserted through the sync-adapter URI so the
|
||||||
|
* provider keeps the row (a plain insert is rejected for the LOCAL account).
|
||||||
|
*/
|
||||||
|
fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
|
||||||
|
|
||||||
|
/** Update name, color and description of a local calendar the app owns. */
|
||||||
|
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?)
|
||||||
|
|
||||||
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
/** Insert a new event; returns the new `Events._ID`. */
|
/** Insert a new event; returns the new `Events._ID`. */
|
||||||
fun insertEvent(form: EventForm): Long
|
fun insertEvent(form: EventForm): Long
|
||||||
|
|
||||||
@@ -105,6 +129,76 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC",
|
||||||
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
)?.use { it.mapAll(::toCalendarSource) } ?: emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar-row writes must address the provider as a sync adapter and name
|
||||||
|
* the account in the URI; otherwise inserts/deletes for the LOCAL account
|
||||||
|
* are silently dropped or only soft-deleted.
|
||||||
|
*/
|
||||||
|
private fun localCalendarsUri(): Uri = CalendarContract.Calendars.CONTENT_URI.buildUpon()
|
||||||
|
.appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true")
|
||||||
|
.appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
|
.appendQueryParameter(
|
||||||
|
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||||
|
CalendarContract.ACCOUNT_TYPE_LOCAL,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
|
||||||
|
// NAME is the sync-adapter id; DISPLAY_NAME is what the user sees.
|
||||||
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||||
|
put(
|
||||||
|
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||||
|
CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||||
|
)
|
||||||
|
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||||
|
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||||
|
putDescription(description)
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(localCalendarsUri(), values)
|
||||||
|
?: throw WriteFailedException("create local calendar '$name'")
|
||||||
|
return ContentUris.parseId(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
||||||
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||||
|
putDescription(description)
|
||||||
|
}
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(localCalendarsUri(), id),
|
||||||
|
values, null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) throw WriteFailedException("update calendar id=$id")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store the description in CAL_SYNC1, or clear it when blank/absent. */
|
||||||
|
private fun ContentValues.putDescription(description: String?) {
|
||||||
|
val text = description?.trim().orEmpty()
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
putNull(CalendarProjection.DESCRIPTION_COLUMN)
|
||||||
|
} else {
|
||||||
|
put(CalendarProjection.DESCRIPTION_COLUMN, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteCalendar(id: Long) {
|
||||||
|
val deleted = resolver.delete(
|
||||||
|
ContentUris.withAppendedId(localCalendarsUri(), id),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||||
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
ContentUris.appendId(this, beginMillis)
|
ContentUris.appendId(this, beginMillis)
|
||||||
@@ -131,6 +225,46 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
|
||||||
|
val account = calendarAccount(calendarId) ?: return emptyList()
|
||||||
|
return resolver.query(
|
||||||
|
CalendarContract.Colors.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Colors.COLOR_KEY, CalendarContract.Colors.COLOR),
|
||||||
|
CalendarContract.Colors.ACCOUNT_NAME + " = ? AND " +
|
||||||
|
CalendarContract.Colors.ACCOUNT_TYPE + " = ? AND " +
|
||||||
|
CalendarContract.Colors.COLOR_TYPE + " = ?",
|
||||||
|
arrayOf(
|
||||||
|
account.name,
|
||||||
|
account.type,
|
||||||
|
CalendarContract.Colors.TYPE_EVENT.toString(),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||||
|
}
|
||||||
|
?.filter { it.key.isNotEmpty() }
|
||||||
|
?.sortedBy { it.key }
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||||
|
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Calendars.ACCOUNT_NAME,
|
||||||
|
CalendarContract.Calendars.ACCOUNT_TYPE,
|
||||||
|
),
|
||||||
|
null, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
if (c.moveToFirst()) {
|
||||||
|
CalendarAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CalendarAccount(val name: String, val type: String)
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm): Long {
|
override fun insertEvent(form: EventForm): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
@@ -156,6 +290,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
form.description.trim().takeIf { it.isNotEmpty() }
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
|
// A null colour just leaves both columns unset (the event inherits
|
||||||
|
// its calendar's colour), so only the key/raw cases are written.
|
||||||
|
when {
|
||||||
|
form.colorKey != null ->
|
||||||
|
put(CalendarContract.Events.EVENT_COLOR_KEY, form.colorKey)
|
||||||
|
form.color != null -> put(CalendarContract.Events.EVENT_COLOR, form.color)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
@@ -425,5 +566,11 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "CalendarDataSource"
|
const val TAG = "CalendarDataSource"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared account for every app-created local calendar, so they group
|
||||||
|
* together (by account) in the filter sheet and calendar manager.
|
||||||
|
*/
|
||||||
|
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,26 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
|
||||||
internal fun ColumnReader.toCalendarSource(): CalendarSource = CalendarSource(
|
internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||||
|
val accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty()
|
||||||
|
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||||
|
return CalendarSource(
|
||||||
id = getLong(CalendarProjection.IDX_ID),
|
id = getLong(CalendarProjection.IDX_ID),
|
||||||
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
|
||||||
?: Fallbacks.UNNAMED_CALENDAR,
|
?: Fallbacks.UNNAMED_CALENDAR,
|
||||||
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
|
||||||
accountType = getString(CalendarProjection.IDX_ACCOUNT_TYPE).orEmpty(),
|
accountType = accountType,
|
||||||
color = getInt(CalendarProjection.IDX_COLOR),
|
color = getInt(CalendarProjection.IDX_COLOR),
|
||||||
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
|
isVisibleInSystem = getInt(CalendarProjection.IDX_VISIBLE) != 0,
|
||||||
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
|
canModifyContents = getInt(CalendarProjection.IDX_ACCESS_LEVEL) >=
|
||||||
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
|
CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR,
|
||||||
|
isLocal = isLocal,
|
||||||
|
// CAL_SYNC1 holds the sync token for synced rows, so only treat it as a
|
||||||
|
// user description on the local calendars the app owns.
|
||||||
|
description = if (isLocal) {
|
||||||
|
getString(CalendarProjection.IDX_DESCRIPTION)?.takeIf { it.isNotBlank() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -12,6 +13,21 @@ interface CalendarRepository {
|
|||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
|
* exposes none (see [CalendarDataSource.eventColorPalette]).
|
||||||
|
*/
|
||||||
|
suspend fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
|
/** Create a device-only (LOCAL) calendar the app owns; returns its id. */
|
||||||
|
suspend fun createLocalCalendar(displayName: String, color: Int, description: String?): Long
|
||||||
|
|
||||||
|
/** Update name, color and description of a local calendar the app owns. */
|
||||||
|
suspend fun updateCalendar(id: Long, displayName: String, color: Int, description: String?)
|
||||||
|
|
||||||
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
|
suspend fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
/** Create a new event from a validated form; returns the new `Events._ID`. */
|
||||||
suspend fun createEvent(form: EventForm): Long
|
suspend fun createEvent(form: EventForm): Long
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -70,6 +71,27 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
withContext(io) { dataSource.eventColorPalette(calendarId) }
|
||||||
|
|
||||||
|
override suspend fun createLocalCalendar(
|
||||||
|
displayName: String,
|
||||||
|
color: Int,
|
||||||
|
description: String?,
|
||||||
|
): Long = withContext(io) {
|
||||||
|
dataSource.createLocalCalendar(displayName, color, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateCalendar(
|
||||||
|
id: Long,
|
||||||
|
displayName: String,
|
||||||
|
color: Int,
|
||||||
|
description: String?,
|
||||||
|
) = withContext(io) { dataSource.updateCalendar(id, displayName, color, description) }
|
||||||
|
|
||||||
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
|
withContext(io) { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
override suspend fun createEvent(form: EventForm): Long = withContext(io) {
|
||||||
dataSource.insertEvent(form)
|
dataSource.insertEvent(form)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,11 +46,16 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
// localized placeholder, and the edit form must prefill the true value.
|
// localized placeholder, and the edit form must prefill the true value.
|
||||||
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
|
val title = getString(EventDetailProjection.IDX_TITLE).orEmpty()
|
||||||
|
|
||||||
val color = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
// The event's own colour (null = inherits the calendar's) is kept apart
|
||||||
getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
// from the resolved display colour: the edit form needs to tell the two
|
||||||
|
// cases apart, while the instance carries the calendar fallback for display.
|
||||||
|
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
|
||||||
|
null
|
||||||
} else {
|
} else {
|
||||||
getInt(EventDetailProjection.IDX_EVENT_COLOR)
|
getInt(EventDetailProjection.IDX_EVENT_COLOR)
|
||||||
}
|
}
|
||||||
|
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
|
||||||
|
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
|
||||||
|
|
||||||
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
val eventId = getLong(EventDetailProjection.IDX_EVENT_ID)
|
||||||
val instance = EventInstance(
|
val instance = EventInstance(
|
||||||
@@ -87,6 +92,8 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
||||||
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
||||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
|
eventColor = eventColor,
|
||||||
|
eventColorKey = eventColorKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ internal fun buildEventUpdateValues(
|
|||||||
if (updated.accessLevel != original.accessLevel) {
|
if (updated.accessLevel != original.accessLevel) {
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
put(CalendarContract.Events.ACCESS_LEVEL, updated.accessLevel.toProviderValue())
|
||||||
}
|
}
|
||||||
|
if (updated.colorKey != original.colorKey || updated.color != original.color) {
|
||||||
|
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||||
|
}
|
||||||
|
|
||||||
val timesChanged = updated.start != original.start ||
|
val timesChanged = updated.start != original.start ||
|
||||||
updated.end != original.end ||
|
updated.end != original.end ||
|
||||||
@@ -134,6 +137,28 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
put(CalendarContract.Events.EVENT_LOCATION, form.location.trim().ifEmpty { null })
|
||||||
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
put(CalendarContract.Events.DESCRIPTION, form.description.trim().ifEmpty { null })
|
||||||
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
* account's palette, so the colour round-trips through sync); a raw [color]
|
||||||
|
* writes `EVENT_COLOR` and clears any key; "no colour" clears both so the event
|
||||||
|
* falls back to its calendar's colour. The two are never written together —
|
||||||
|
* the provider rejects a raw colour on a calendar that publishes a palette,
|
||||||
|
* which is exactly why palette calendars only ever go through the key.
|
||||||
|
*/
|
||||||
|
internal fun eventColorColumns(colorKey: String?, color: Int?): Map<String, Any?> = when {
|
||||||
|
colorKey != null -> mapOf(CalendarContract.Events.EVENT_COLOR_KEY to colorKey)
|
||||||
|
color != null -> mapOf(
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY to null,
|
||||||
|
CalendarContract.Events.EVENT_COLOR to color,
|
||||||
|
)
|
||||||
|
else -> mapOf(
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY to null,
|
||||||
|
CalendarContract.Events.EVENT_COLOR to null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ internal object CalendarProjection {
|
|||||||
CalendarContract.Calendars.CALENDAR_COLOR,
|
CalendarContract.Calendars.CALENDAR_COLOR,
|
||||||
CalendarContract.Calendars.VISIBLE,
|
CalendarContract.Calendars.VISIBLE,
|
||||||
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||||
|
// CalendarContract has no description column; for the local calendars we
|
||||||
|
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
||||||
|
// so the mapper only reads it for local calendars).
|
||||||
|
DESCRIPTION_COLUMN,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
const val IDX_DISPLAY_NAME = 1
|
const val IDX_DISPLAY_NAME = 1
|
||||||
const val IDX_ACCOUNT_NAME = 2
|
const val IDX_ACCOUNT_NAME = 2
|
||||||
@@ -20,6 +26,7 @@ internal object CalendarProjection {
|
|||||||
const val IDX_COLOR = 4
|
const val IDX_COLOR = 4
|
||||||
const val IDX_VISIBLE = 5
|
const val IDX_VISIBLE = 5
|
||||||
const val IDX_ACCESS_LEVEL = 6
|
const val IDX_ACCESS_LEVEL = 6
|
||||||
|
const val IDX_DESCRIPTION = 7
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
@@ -67,6 +74,7 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.ACCESS_LEVEL,
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -86,6 +94,7 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_ACCESS_LEVEL = 14
|
const val IDX_ACCESS_LEVEL = 14
|
||||||
const val IDX_EVENT_TIMEZONE = 15
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
|
|||||||
@@ -99,6 +99,22 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
|
store.edit { it[REMINDERS_ENABLED_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to offer a custom event colour even on calendars that publish no
|
||||||
|
* colour palette (most local calendars handle it fine; synced calendars
|
||||||
|
* without a palette — some CalDAV — may drop or overwrite a raw colour on
|
||||||
|
* their next sync). Defaults to OFF: such calendars hide the colour picker
|
||||||
|
* until the user opts in, accepting the limitation. Local calendars and
|
||||||
|
* palette-backed calendars (Google, …) are unaffected by this flag.
|
||||||
|
*/
|
||||||
|
val allowColorOnUnsupportedCalendars: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ALLOW_COLOR_UNSUPPORTED_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
|
store.edit { it[ALLOW_COLOR_UNSUPPORTED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the one-time reminder onboarding step (after the calendar
|
* Whether the one-time reminder onboarding step (after the calendar
|
||||||
* grant) has been shown — also true for users who tapped "not now".
|
* grant) has been shown — also true for users who tapped "not now".
|
||||||
@@ -125,6 +141,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
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")
|
||||||
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
|
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ data class EventForm(
|
|||||||
* those are kept verbatim until the user picks something else.
|
* those are kept verbatim until the user picks something else.
|
||||||
*/
|
*/
|
||||||
val rrule: String? = null,
|
val rrule: String? = null,
|
||||||
|
/**
|
||||||
|
* The event's own colour, or null to inherit the calendar's colour.
|
||||||
|
* [colorKey] is a `Colors.COLOR_KEY` from the calendar account's published
|
||||||
|
* event palette (Google, some CalDAV) — written as `EVENT_COLOR_KEY` so it
|
||||||
|
* round-trips through sync. When it is null but [color] is set, [color] is
|
||||||
|
* a raw ARGB written as `EVENT_COLOR` (local calendars, or synced ones the
|
||||||
|
* user opted into despite no palette). [color] mirrors the key's swatch when
|
||||||
|
* [colorKey] is set, so the picker can highlight it.
|
||||||
|
*/
|
||||||
|
val colorKey: String? = null,
|
||||||
|
val color: Int? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +54,7 @@ enum class EventFormField {
|
|||||||
Recurrence,
|
Recurrence,
|
||||||
Availability,
|
Availability,
|
||||||
Visibility,
|
Visibility,
|
||||||
|
Color,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class EventFormProblem {
|
enum class EventFormProblem {
|
||||||
@@ -91,6 +103,11 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
availability = availability,
|
availability = availability,
|
||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
// The provider fills EVENT_COLOR from the key, so [color] is the
|
||||||
|
// swatch either way; a null colour means the event inherits its
|
||||||
|
// calendar's colour.
|
||||||
|
colorKey = eventColorKey,
|
||||||
|
color = eventColor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +147,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
|||||||
if (rrule != null) add(EventFormField.Recurrence)
|
if (rrule != null) add(EventFormField.Recurrence)
|
||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||||
|
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ data class CalendarSource(
|
|||||||
* subscriptions, birthday calendars and other read-only sources.
|
* subscriptions, birthday calendars and other read-only sources.
|
||||||
*/
|
*/
|
||||||
val canModifyContents: Boolean = false,
|
val canModifyContents: Boolean = false,
|
||||||
|
/**
|
||||||
|
* A device-only calendar the app itself owns (`ACCOUNT_TYPE_LOCAL`): it has
|
||||||
|
* no sync backend, so the app can rename / recolor / delete it. Synced
|
||||||
|
* calendars (Google, DAVx5, …) are managed in their own source app instead.
|
||||||
|
*/
|
||||||
|
val isLocal: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Free-text note for a local calendar (stored in `CAL_SYNC1`, which the app
|
||||||
|
* owns for its own calendars). Always null for synced calendars.
|
||||||
|
*/
|
||||||
|
val description: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
@@ -47,8 +58,25 @@ data class EventDetail(
|
|||||||
val eventTimezone: String? = null,
|
val eventTimezone: String? = null,
|
||||||
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
||||||
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
|
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
|
||||||
|
/**
|
||||||
|
* The event's own raw colour (`Events.EVENT_COLOR`), null when the event
|
||||||
|
* inherits its calendar's colour. Unlike [EventInstance.color] (which
|
||||||
|
* already folds in the calendar fallback for display) this stays null so
|
||||||
|
* the edit form can tell "has own colour" from "inherits".
|
||||||
|
*/
|
||||||
|
val eventColor: Int? = null,
|
||||||
|
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||||
|
val eventColorKey: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable event colour published by a calendar's account
|
||||||
|
* (`CalendarContract.Colors`, `TYPE_EVENT`): [key] is the account-scoped
|
||||||
|
* `COLOR_KEY` written as `EVENT_COLOR_KEY` (so the colour survives sync),
|
||||||
|
* [argb] is the swatch it renders as.
|
||||||
|
*/
|
||||||
|
data class EventColorOption(val key: String, val argb: Int)
|
||||||
|
|
||||||
data class Attendee(
|
data class Attendee(
|
||||||
val name: String,
|
val name: String,
|
||||||
val email: String?,
|
val email: String?,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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 de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
@@ -86,13 +87,23 @@ fun CalendarHost(
|
|||||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
val onOpenSettings = { showSettings = true }
|
val onOpenSettings = { showSettings = true }
|
||||||
|
|
||||||
|
// Calendar manager (reached from Settings) — its own overlay so it slides
|
||||||
|
// over Settings and survives view switches.
|
||||||
|
var showCalendars by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
// Event form (v1.2 create) — same held-key pattern as the detail screen:
|
||||||
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
// [heldCreateIso] keeps the prefill date alive through the slide-out.
|
||||||
|
// [createStartMinutes] is the tapped slot's start (minutes from midnight)
|
||||||
|
// when the form is opened from a day/week grid tap; null from the FAB.
|
||||||
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
|
var createDateIso by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
||||||
val onCreateEvent: (LocalDate) -> Unit = { date ->
|
var createStartMinutes by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||||
|
var heldCreateMinutes by remember { mutableStateOf<Int?>(null) }
|
||||||
|
val onCreateEvent: (LocalDate, Int?) -> Unit = { date, startMinutes ->
|
||||||
heldCreateIso = date.toString()
|
heldCreateIso = date.toString()
|
||||||
createDateIso = date.toString()
|
createDateIso = date.toString()
|
||||||
|
heldCreateMinutes = startMinutes
|
||||||
|
createStartMinutes = startMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
// Edit form (v1.3) — reuses the detail screen's occurrence key; for
|
||||||
@@ -162,6 +173,7 @@ fun CalendarHost(
|
|||||||
(createDateIso ?: heldCreateIso)?.let { iso ->
|
(createDateIso ?: heldCreateIso)?.let { iso ->
|
||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = iso,
|
initialDateIso = iso,
|
||||||
|
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
onSaved = { createDateIso = null },
|
onSaved = { createDateIso = null },
|
||||||
)
|
)
|
||||||
@@ -193,7 +205,19 @@ fun CalendarHost(
|
|||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
SettingsScreen(onBack = { showSettings = false })
|
SettingsScreen(
|
||||||
|
onBack = { showSettings = false },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar manager — slides over Settings.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showCalendars,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
CalendarsScreen(onBack = { showCalendars = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.OpenInNew
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
|
||||||
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||||
|
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||||
|
* and lists synced calendars read-only with a per-account "manage in the source
|
||||||
|
* app" deep-link — the app never touches a synced calendar's server. A
|
||||||
|
* full-screen destination; [onBack] pops it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||||
|
// [editorSession] bumps on every open so the editor's field state resets for
|
||||||
|
// a fresh open while still surviving configuration changes within one open.
|
||||||
|
var editorId by rememberSaveable { mutableStateOf<Long?>(null) }
|
||||||
|
var editorSession by rememberSaveable { mutableStateOf(0) }
|
||||||
|
|
||||||
|
if (editorId != null) {
|
||||||
|
val editing = calendars.firstOrNull { it.id == editorId }
|
||||||
|
CalendarEditor(
|
||||||
|
sessionKey = editorSession,
|
||||||
|
isNew = editorId == NEW_CALENDAR_ID,
|
||||||
|
initialName = editing?.displayName.orEmpty(),
|
||||||
|
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
||||||
|
initialDescription = editing?.description.orEmpty(),
|
||||||
|
onSave = { name, color, description ->
|
||||||
|
val id = editorId
|
||||||
|
if (id == null || id == NEW_CALENDAR_ID) {
|
||||||
|
viewModel.createCalendar(name, color, description)
|
||||||
|
} else {
|
||||||
|
viewModel.updateCalendar(id, name, color, description)
|
||||||
|
}
|
||||||
|
editorId = null
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
editorId?.takeIf { it != NEW_CALENDAR_ID }?.let(viewModel::deleteCalendar)
|
||||||
|
editorId = null
|
||||||
|
},
|
||||||
|
onClose = { editorId = null },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CalendarsList(
|
||||||
|
local = calendars.filter { it.isLocal },
|
||||||
|
synced = calendars.filterNot { it.isLocal },
|
||||||
|
error = error,
|
||||||
|
onConsumeError = viewModel::consumeError,
|
||||||
|
onBack = onBack,
|
||||||
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CalendarsList(
|
||||||
|
local: List<CalendarSource>,
|
||||||
|
synced: List<CalendarSource>,
|
||||||
|
error: Boolean,
|
||||||
|
onConsumeError: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onAdd: () -> Unit,
|
||||||
|
onEdit: (CalendarSource) -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||||
|
|
||||||
|
LaunchedEffect(error) {
|
||||||
|
if (error) {
|
||||||
|
snackbarHostState.showSnackbar(writeErrorText)
|
||||||
|
onConsumeError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.calendars_title),
|
||||||
|
onBack = onBack,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
) {
|
||||||
|
// Local (device-only) calendars — the calendars the app owns. The
|
||||||
|
// "Add calendar" entry closes the group as its final row.
|
||||||
|
SectionHeader(stringResource(R.string.calendars_local_header))
|
||||||
|
if (local.isEmpty()) {
|
||||||
|
HintText(stringResource(R.string.calendars_local_empty))
|
||||||
|
}
|
||||||
|
val localCount = local.size + 1
|
||||||
|
local.forEachIndexed { index, calendar ->
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = calendar.description,
|
||||||
|
position = positionOf(index, localCount),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = stringResource(R.string.calendars_edit_title),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { onEdit(calendar) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_add),
|
||||||
|
position = positionOf(local.size, localCount),
|
||||||
|
leading = { AddAvatar() },
|
||||||
|
onClick = onAdd,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Synced calendars — read-only, grouped by account, each with a
|
||||||
|
// per-account "manage in source app" link.
|
||||||
|
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||||
|
HintText(stringResource(R.string.calendars_synced_hint))
|
||||||
|
synced
|
||||||
|
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||||
|
.forEach { (account, cals) ->
|
||||||
|
AccountHeader(account = account, accountType = cals.first().accountType)
|
||||||
|
cals.forEachIndexed { index, calendar ->
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = positionOf(index, cals.size),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_add_account),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { AddAvatar() },
|
||||||
|
onClick = {
|
||||||
|
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun CalendarEditor(
|
||||||
|
sessionKey: Int,
|
||||||
|
isNew: Boolean,
|
||||||
|
initialName: String,
|
||||||
|
initialColor: Int,
|
||||||
|
initialDescription: String,
|
||||||
|
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||||
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
|
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||||
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
BackHandler(onBack = onClose)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (isNew) R.string.calendars_new_title
|
||||||
|
else R.string.calendars_edit_title,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.event_edit_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (!isNew) {
|
||||||
|
IconButton(onClick = { confirmDelete = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_delete),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filled save button, matching the event editor's top bar.
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSave(name.trim(), color, description.trim().ifEmpty { null })
|
||||||
|
},
|
||||||
|
enabled = name.isNotBlank(),
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.event_edit_save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) {
|
||||||
|
InlineTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
placeholder = stringResource(R.string.calendars_name_label),
|
||||||
|
textStyle = MaterialTheme.typography.titleLarge,
|
||||||
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EditorCard(
|
||||||
|
icon = Icons.Default.Palette,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
iconAtTop = true,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_color_label),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
ColorSwatchRow(
|
||||||
|
colors = CALENDAR_COLOR_PALETTE,
|
||||||
|
selected = color,
|
||||||
|
onSelect = { color = it },
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EditorCard(
|
||||||
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
|
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
iconAtTop = true,
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
value = description,
|
||||||
|
onValueChange = { description = it },
|
||||||
|
placeholder = stringResource(R.string.calendars_description_hint),
|
||||||
|
singleLine = false,
|
||||||
|
minLines = 2,
|
||||||
|
capitalization = KeyboardCapitalization.Sentences,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmDelete) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmDelete = false },
|
||||||
|
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
confirmDelete = false
|
||||||
|
onDelete()
|
||||||
|
}) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.event_detail_delete),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { confirmDelete = false }) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonal field card matching the event editor's design (icon + content). */
|
||||||
|
@Composable
|
||||||
|
private fun EditorCard(
|
||||||
|
icon: ImageVector,
|
||||||
|
iconTint: Color,
|
||||||
|
iconAtTop: Boolean = false,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = if (iconAtTop) 2.dp else 0.dp)
|
||||||
|
.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountHeader(account: String, accountType: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = account,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
OutlinedButton(onClick = {
|
||||||
|
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.calendars_manage_in_app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
||||||
|
@Composable
|
||||||
|
private fun AddAvatar() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HintText(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the app to open for managing a synced calendar's account. The account's
|
||||||
|
* own authenticator package (resolved from [AccountManager], no permission
|
||||||
|
* needed) handles any sync provider — DAVx5, ICSx5, Nextcloud, … — and a small
|
||||||
|
* curated map redirects the few cases where the authenticator isn't the app to
|
||||||
|
* open (Google's authenticator is Play Services, but users want the Calendar
|
||||||
|
* app). Falls back to the system account settings when nothing launchable is
|
||||||
|
* found, so the button always lands somewhere sensible.
|
||||||
|
*/
|
||||||
|
private fun sourceAppIntent(context: Context, accountType: String): Intent {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val candidates = buildList {
|
||||||
|
AccountManager.get(context).authenticatorTypes
|
||||||
|
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||||
|
?.packageName
|
||||||
|
?.let { add(it) }
|
||||||
|
curatedSourcePackage(accountType)?.let { add(it) }
|
||||||
|
}
|
||||||
|
for (pkg in candidates) {
|
||||||
|
pm.getLaunchIntentForPackage(pkg)?.let { return it }
|
||||||
|
}
|
||||||
|
return Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||||
|
private fun curatedSourcePackage(accountType: String): String? = when {
|
||||||
|
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs the calendar manager: lists every calendar (the screen splits them into
|
||||||
|
* the app's own local calendars and read-only/synced ones) and creates,
|
||||||
|
* renames, recolors or deletes the local calendars the app owns. Write failures
|
||||||
|
* flip [error] so the screen can surface a one-shot message.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class CalendarsViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val calendars: StateFlow<List<CalendarSource>> =
|
||||||
|
repository.calendars()
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _error = MutableStateFlow(false)
|
||||||
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
|
fun consumeError() { _error.value = false }
|
||||||
|
|
||||||
|
fun createCalendar(displayName: String, color: Int, description: String?) = write {
|
||||||
|
repository.createLocalCalendar(displayName, color, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) = write {
|
||||||
|
repository.updateCalendar(id, displayName, color, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCalendar(id: Long) = write {
|
||||||
|
repository.deleteCalendar(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||||
@@ -15,3 +29,27 @@ fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
|||||||
hsv[2] = if (dark) 0.82f else 0.72f
|
hsv[2] = if (dark) 0.82f else 0.72f
|
||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||||
|
* in the calendar's (pastelised) colour. Shared by the calendar manager and the
|
||||||
|
* visibility filter so they read identically.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.CalendarMonth,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = pastelize(color, dark),
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -23,17 +36,12 @@ import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
|||||||
/**
|
/**
|
||||||
* Navigation drawer shared by every top-level calendar screen.
|
* Navigation drawer shared by every top-level calendar screen.
|
||||||
*
|
*
|
||||||
* Visual language (kept deliberately small so sizes don't drift):
|
* Uses the app's grouped-card design system (see [GroupedRow]): a branded
|
||||||
* - Drawer title — `titleLarge`
|
* header, the View switcher as a grouped card (the active view highlighted),
|
||||||
* - Section headers (e.g. "Calendars") — `titleSmall`, primary, text only
|
* the per-calendar visibility filter (M3) inline, and a pinned Settings row.
|
||||||
* - Nav items (the views, Today / Settings) — Material `NavigationDrawerItem`
|
* The "View" section mirrors the top-bar switcher pill — tapping a view here
|
||||||
* (`labelLarge` label + a single 24dp leading icon)
|
* selects it (and closes the drawer) rather than cycling. The host screen owns
|
||||||
*
|
* the drawer state.
|
||||||
* The "View" section mirrors the top-bar switcher pill: tapping a view here
|
|
||||||
* selects it (and closes the drawer) rather than cycling. Also hosts the
|
|
||||||
* per-calendar visibility filter (M3) inline — the calendar list with its
|
|
||||||
* checkboxes lives here rather than in a separate sheet — plus a Settings
|
|
||||||
* entry (M4). The host screen owns the drawer state.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarDrawer(
|
fun CalendarDrawer(
|
||||||
@@ -42,43 +50,72 @@ fun CalendarDrawer(
|
|||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
Column(Modifier.fillMaxHeight()) {
|
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||||
|
// and Settings all flow in a single scroll container.
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
DrawerHeader()
|
||||||
|
|
||||||
|
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||||
|
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(view.labelRes),
|
||||||
|
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||||
|
selected = view == currentView,
|
||||||
|
minHeight = 56.dp,
|
||||||
|
leading = { Icon(view.icon, contentDescription = null) },
|
||||||
|
onClick = { onSelectView(view) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
DrawerSectionHeader(stringResource(R.string.filter_title))
|
||||||
|
CalendarFilterList()
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.month_action_settings),
|
||||||
|
position = Position.Alone,
|
||||||
|
minHeight = 56.dp,
|
||||||
|
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||||
|
onClick = onSettings,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branded header: the app-icon chip beside the app name. */
|
||||||
|
@Composable
|
||||||
|
private fun DrawerHeader() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.requiredSize(66.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.app_name),
|
text = stringResource(R.string.app_name),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
|
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
|
||||||
IMPLEMENTED_VIEWS.forEach { view ->
|
|
||||||
NavigationDrawerItem(
|
|
||||||
icon = { Icon(view.icon, contentDescription = null) },
|
|
||||||
label = { Text(stringResource(view.labelRes)) },
|
|
||||||
selected = view == currentView,
|
|
||||||
onClick = { onSelectView(view) },
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
// Calendars (M3) — visibility checkboxes, scrollable, takes the slack
|
|
||||||
// between the top actions and the pinned Settings entry.
|
|
||||||
DrawerSectionHeader(stringResource(R.string.filter_title))
|
|
||||||
CalendarFilterList(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
NavigationDrawerItem(
|
|
||||||
icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
|
||||||
label = { Text(stringResource(R.string.month_action_settings)) },
|
|
||||||
selected = false,
|
|
||||||
onClick = onSettings,
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||||
|
* ringed and checked. Shared by the calendar editor and the event-colour
|
||||||
|
* picker so both pick a colour the same way. Swatches render through
|
||||||
|
* [pastelize] — the softened colour the app actually paints, not the raw hue.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ColorSwatchRow(
|
||||||
|
colors: List<Int>,
|
||||||
|
selected: Int?,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
dark: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
colors.forEach { argb ->
|
||||||
|
val isSelected = argb == selected
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(pastelize(argb, dark))
|
||||||
|
.then(
|
||||||
|
if (isSelected) {
|
||||||
|
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.clickable { onSelect(argb) },
|
||||||
|
) {
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Black.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
|
||||||
|
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
|
||||||
|
0xFFD50000, // red
|
||||||
|
0xFFE67C00, // orange
|
||||||
|
0xFFF6BF26, // amber
|
||||||
|
0xFF33B679, // green
|
||||||
|
0xFF0B8043, // dark green
|
||||||
|
0xFF039BE5, // blue
|
||||||
|
0xFF3F51B5, // indigo
|
||||||
|
0xFF8E24AA, // purple
|
||||||
|
0xFF616161, // graphite
|
||||||
|
).map { it.toInt() }
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LargeTopAppBar
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position of a row within a grouped list, after the Android-15 settings
|
||||||
|
* pattern: a run of rows shares one rounded container, with full corners at the
|
||||||
|
* group's outer edges and small corners between, separated by small gaps.
|
||||||
|
*/
|
||||||
|
enum class Position { Top, Middle, Bottom, Alone }
|
||||||
|
|
||||||
|
/** Maps an index within a group of [count] rows to its [Position]. */
|
||||||
|
fun positionOf(index: Int, count: Int): Position = when {
|
||||||
|
count <= 1 -> Position.Alone
|
||||||
|
index == 0 -> Position.Top
|
||||||
|
index == count - 1 -> Position.Bottom
|
||||||
|
else -> Position.Middle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
|
||||||
|
* whose title shrinks into the bar (next to the back button) as the content
|
||||||
|
* scrolls. Content is a scrollable column that feeds the toolbar via nested
|
||||||
|
* scroll. Used by Settings and the calendar manager so they share one shell.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CollapsingScaffold(
|
||||||
|
title: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
snackbarHost: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
val scrollBehavior =
|
||||||
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.settings_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = snackbarHost,
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(top = 8.dp, bottom = 24.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
||||||
|
* corner radii come from its [position] (so a run of rows reads as a single
|
||||||
|
* rounded card). Corners round further on press. A null [onClick] makes the
|
||||||
|
* row non-interactive (e.g. read-only entries).
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GroupedRow(
|
||||||
|
title: String,
|
||||||
|
position: Position,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
summary: String? = null,
|
||||||
|
selected: Boolean = false,
|
||||||
|
minHeight: Dp = 72.dp,
|
||||||
|
leading: @Composable (() -> Unit)? = null,
|
||||||
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
|
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
||||||
|
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
||||||
|
val shape = when (position) {
|
||||||
|
Position.Alone -> RoundedCornerShape(full)
|
||||||
|
Position.Top -> RoundedCornerShape(
|
||||||
|
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||||
|
)
|
||||||
|
Position.Middle -> RoundedCornerShape(small)
|
||||||
|
Position.Bottom -> RoundedCornerShape(
|
||||||
|
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val gap = when (position) {
|
||||||
|
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||||
|
Position.Bottom, Position.Alone -> Modifier
|
||||||
|
}
|
||||||
|
val itemColors = if (selected) {
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
|
}
|
||||||
|
val item: @Composable () -> Unit = {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(title) },
|
||||||
|
supportingContent = summary?.let { text -> { Text(text) } },
|
||||||
|
leadingContent = leading,
|
||||||
|
trailingContent = trailing,
|
||||||
|
colors = itemColors,
|
||||||
|
modifier = Modifier.heightIn(min = minHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val base = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.then(gap)
|
||||||
|
val containerColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
if (onClick != null) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
color = containerColor,
|
||||||
|
shape = shape,
|
||||||
|
interactionSource = interaction,
|
||||||
|
modifier = base,
|
||||||
|
) { item() }
|
||||||
|
} else {
|
||||||
|
Surface(color = containerColor, shape = shape, modifier = base) { item() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's borderless text input: no underline, no outline, just the tonal
|
||||||
|
* card behind it. This is the standard input across the app — we deliberately
|
||||||
|
* don't use Material's outlined/filled text fields, so anything that takes text
|
||||||
|
* (the event form, the calendar manager, dialogs) uses this inside a tonal
|
||||||
|
* [androidx.compose.material3.Surface].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InlineTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
singleLine: Boolean = true,
|
||||||
|
minLines: Int = 1,
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||||
|
) {
|
||||||
|
val resolvedStyle = textStyle.copy(
|
||||||
|
color = if (textStyle.color.isSpecified) {
|
||||||
|
textStyle.color
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
textStyle = resolvedStyle,
|
||||||
|
singleLine = singleLine,
|
||||||
|
minLines = minLines,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = keyboardType,
|
||||||
|
capitalization = capitalization,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box {
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
// Clearly fainter than typed text, so a hint never reads as
|
||||||
|
// prefilled content.
|
||||||
|
Text(
|
||||||
|
text = placeholder,
|
||||||
|
style = resolvedStyle,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateDpAsState
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
@@ -104,7 +105,7 @@ fun DayScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onCreateEvent: (LocalDate) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -185,7 +186,7 @@ fun DayScreen(
|
|||||||
todayVisible = !isOnToday,
|
todayVisible = !isOnToday,
|
||||||
todayText = stringResource(R.string.day_today_action),
|
todayText = stringResource(R.string.day_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = { onCreateEvent(date) },
|
onCreate = { onCreateEvent(date, null) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
@@ -197,6 +198,7 @@ fun DayScreen(
|
|||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
@@ -214,6 +216,7 @@ private fun DayContent(
|
|||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -282,6 +285,7 @@ private fun DayContent(
|
|||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -294,6 +298,7 @@ private fun DaySuccess(
|
|||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
@@ -309,7 +314,12 @@ private fun DaySuccess(
|
|||||||
// Breathing room between the (colour-shifting) top section and the
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
// scrolling timeline below.
|
// scrolling timeline below.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
|
Timeline(
|
||||||
|
state = state,
|
||||||
|
scrollState = scrollState,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +437,7 @@ private fun Timeline(
|
|||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -474,7 +485,9 @@ private fun Timeline(
|
|||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
date = state.date,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight),
|
.height(totalHeight),
|
||||||
@@ -488,9 +501,12 @@ private fun Timeline(
|
|||||||
private fun DayColumnCard(
|
private fun DayColumnCard(
|
||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
date: LocalDate,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
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.
|
||||||
@@ -500,7 +516,19 @@ private fun DayColumnCard(
|
|||||||
),
|
),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Tap an empty slot to create an event there. Taps on event
|
||||||
|
// blocks are consumed by their own click handler first, so this
|
||||||
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
|
.pointerInput(date) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||||
|
onCreateAt(date, hour * 60)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
val laneWidth = colWidth / block.laneCount
|
val laneWidth = colWidth / block.laneCount
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import androidx.compose.material.icons.filled.Close
|
|||||||
import androidx.compose.material.icons.filled.EventAvailable
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
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.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
@@ -102,6 +103,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
@@ -110,6 +112,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.CALENDAR_COLOR_PALETTE
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
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.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
@@ -147,6 +152,7 @@ fun EventEditScreen(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onSaved: () -> Unit,
|
onSaved: () -> Unit,
|
||||||
editKey: LongArray? = null,
|
editKey: LongArray? = null,
|
||||||
|
initialStartMinutes: Int? = null,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey) {
|
LaunchedEffect(initialDateIso, editKey) {
|
||||||
@@ -159,7 +165,7 @@ fun EventEditScreen(
|
|||||||
} else {
|
} else {
|
||||||
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
viewModel.openNew(date)
|
viewModel.openNew(date, initialStartMinutes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -412,6 +418,7 @@ private fun EventEditContent(
|
|||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||||
@@ -421,6 +428,16 @@ private fun EventEditContent(
|
|||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
|
// Per-event colour applicability for the resolved calendar:
|
||||||
|
// - palette calendars (Google, …) and local calendars always support it;
|
||||||
|
// - synced calendars with no palette only when the user opted in, and even
|
||||||
|
// then the colour may not survive the calendar's next sync (the warning).
|
||||||
|
val isLocalCalendar = selectedCalendar?.isLocal == true
|
||||||
|
val colorSupported = state.colorPalette.isNotEmpty() || isLocalCalendar ||
|
||||||
|
state.allowColorOnUnsupportedCalendars
|
||||||
|
val colorSyncRisk = state.colorPalette.isEmpty() && !isLocalCalendar &&
|
||||||
|
state.allowColorOnUnsupportedCalendars
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
// Shrink the scroll viewport by the keyboard instead of letting
|
// Shrink the scroll viewport by the keyboard instead of letting
|
||||||
@@ -690,6 +707,67 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OptionalFormSection(visible = EventFormField.Color in state.visibleFields) {
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
// The swatch the event will paint with: its own colour, else the
|
||||||
|
// calendar's. The Palette icon takes that colour as a preview.
|
||||||
|
val swatch = form.color ?: selectedCalendar?.color
|
||||||
|
EditCard(
|
||||||
|
icon = Icons.Default.Palette,
|
||||||
|
iconContentDescription = stringResource(R.string.event_edit_color),
|
||||||
|
iconTint = if (colorSupported && swatch != null) {
|
||||||
|
pastelize(swatch, dark)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
onClick = { showColorPicker = true }.takeIf { colorSupported },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
when {
|
||||||
|
!colorSupported -> R.string.event_edit_color_unsupported
|
||||||
|
form.color != null -> R.string.event_edit_color_custom
|
||||||
|
else -> R.string.event_edit_color_default
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
if (colorSupported) {
|
||||||
|
R.string.event_edit_color
|
||||||
|
} else {
|
||||||
|
R.string.event_edit_color_unsupported_hint
|
||||||
|
},
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (colorSyncRisk) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_color_sync_warning),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colorSupported) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -777,6 +855,28 @@ private fun EventEditContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showColorPicker) {
|
||||||
|
ColorPickerDialog(
|
||||||
|
palette = state.colorPalette,
|
||||||
|
selected = form.color,
|
||||||
|
hasExplicitColor = form.color != null,
|
||||||
|
syncWarning = colorSyncRisk,
|
||||||
|
onPickKey = { key, argb ->
|
||||||
|
viewModel.setColorKey(key, argb)
|
||||||
|
showColorPicker = false
|
||||||
|
},
|
||||||
|
onPickRaw = { argb ->
|
||||||
|
viewModel.setColorRaw(argb)
|
||||||
|
showColorPicker = false
|
||||||
|
},
|
||||||
|
onClear = {
|
||||||
|
viewModel.clearColor()
|
||||||
|
showColorPicker = false
|
||||||
|
},
|
||||||
|
onDismiss = { showColorPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showFieldPicker) {
|
if (showFieldPicker) {
|
||||||
FieldPickerDialog(
|
FieldPickerDialog(
|
||||||
hiddenFields = state.hiddenFields,
|
hiddenFields = state.hiddenFields,
|
||||||
@@ -1292,6 +1392,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||||
@@ -1301,6 +1402,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
|||||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
EventFormField.Visibility -> Icons.Default.Lock
|
EventFormField.Visibility -> Icons.Default.Lock
|
||||||
|
EventFormField.Color -> Icons.Default.Palette
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1334,6 +1436,72 @@ private fun VisibilityPickerDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-colour picker: just the swatches. A non-empty [palette] (the calendar
|
||||||
|
* account's published colours) picks by key so the colour round-trips through
|
||||||
|
* sync; otherwise the app's own palette writes a raw colour, with a
|
||||||
|
* [syncWarning] when that calendar may not keep it. The "Reset" button (shown
|
||||||
|
* only once a colour is set) drops back to the calendar's own colour.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ColorPickerDialog(
|
||||||
|
palette: List<EventColorOption>,
|
||||||
|
selected: Int?,
|
||||||
|
hasExplicitColor: Boolean,
|
||||||
|
syncWarning: Boolean,
|
||||||
|
onPickKey: (String, Int) -> Unit,
|
||||||
|
onPickRaw: (Int) -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.event_edit_color)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
if (palette.isNotEmpty()) {
|
||||||
|
ColorSwatchRow(
|
||||||
|
colors = palette.map { it.argb },
|
||||||
|
selected = selected,
|
||||||
|
onSelect = { argb ->
|
||||||
|
palette.firstOrNull { it.argb == argb }
|
||||||
|
?.let { onPickKey(it.key, it.argb) }
|
||||||
|
},
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ColorSwatchRow(
|
||||||
|
colors = CALENDAR_COLOR_PALETTE,
|
||||||
|
selected = selected,
|
||||||
|
onSelect = onPickRaw,
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
|
if (syncWarning) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_color_sync_warning),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
dismissButton = if (hasExplicitColor) {
|
||||||
|
{
|
||||||
|
TextButton(onClick = onClear) {
|
||||||
|
Text(stringResource(R.string.event_edit_color_reset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
|
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
|
||||||
AccessLevel.Default -> Icons.Default.Tune
|
AccessLevel.Default -> Icons.Default.Tune
|
||||||
AccessLevel.Public -> Icons.Default.Public
|
AccessLevel.Public -> Icons.Default.Public
|
||||||
@@ -1436,8 +1604,9 @@ private fun EditCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Borderless text input used inside the cards (and as the headline title) —
|
* Borderless text input used inside the cards (and as the headline title).
|
||||||
* no underline, no outline, just the card's tonal surface behind it.
|
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||||
|
* the app share one input style.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun InlineField(
|
private fun InlineField(
|
||||||
@@ -1452,36 +1621,15 @@ private fun InlineField(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
val resolvedStyle = textStyle.copy(
|
InlineTextField(
|
||||||
color = if (textStyle.color.isSpecified) {
|
|
||||||
textStyle.color
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
)
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
textStyle = resolvedStyle,
|
placeholder = placeholder,
|
||||||
|
modifier = modifier,
|
||||||
|
textStyle = textStyle,
|
||||||
singleLine = singleLine,
|
singleLine = singleLine,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
keyboardType = keyboardType,
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
// Clearly fainter than typed text, so a hint (e.g. the
|
|
||||||
// "10" in the reminder amount) never reads as prefilled.
|
|
||||||
Text(
|
|
||||||
text = placeholder,
|
|
||||||
style = resolvedStyle,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.edit
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
@@ -33,6 +34,18 @@ data class EventEditUiState(
|
|||||||
* then drops "only this event" (an exception row can't carry a rule).
|
* then drops "only this event" (an exception row can't carry a rule).
|
||||||
*/
|
*/
|
||||||
val recurrenceChanged: Boolean = false,
|
val recurrenceChanged: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The event-colour palette the resolved target calendar publishes; empty
|
||||||
|
* when it exposes none. Non-empty → the colour picker offers these swatches
|
||||||
|
* (written as a key, sync-safe); empty → see [colorMode].
|
||||||
|
*/
|
||||||
|
val colorPalette: List<EventColorOption> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Whether the user has opted into custom colours on calendars that publish
|
||||||
|
* no palette (a synced one may then drop the colour on sync). Mirrors the
|
||||||
|
* settings flag; ignored for local and palette-backed calendars.
|
||||||
|
*/
|
||||||
|
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
|
|||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
@@ -19,12 +20,17 @@ import de.jeanlucmakiola.calendula.domain.populatedFields
|
|||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -98,19 +104,44 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
val defaultFields: Set<EventFormField>,
|
val defaultFields: Set<EventFormField>,
|
||||||
|
val allowColorOnUnsupported: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Writable calendars — the only valid event targets. */
|
||||||
|
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.canModifyContents } }
|
||||||
|
.catch { emit(emptyList()) }
|
||||||
|
|
||||||
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
|
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||||
|
_form.map { it?.calendarId },
|
||||||
|
writableCalendars,
|
||||||
|
prefs.lastUsedCalendarId,
|
||||||
|
) { picked, writable, lastUsed ->
|
||||||
|
picked
|
||||||
|
?: lastUsed?.takeIf { id -> writable.any { it.id == id } }
|
||||||
|
?: writable.firstOrNull()?.id
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
|
/** The resolved calendar's published event palette, refetched when it changes. */
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private val colorPalette: Flow<List<EventColorOption>> = resolvedCalendarId
|
||||||
|
.flatMapLatest { id ->
|
||||||
|
flow { emit(id?.let { repository.eventColorPalette(it) }.orEmpty()) }
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
|
||||||
val state: StateFlow<EventEditUiState?> = combine(
|
val state: StateFlow<EventEditUiState?> = combine(
|
||||||
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
|
combine(_form, _saveState, _showProblems, _revealed, _editTarget, ::LocalInputs),
|
||||||
combine(
|
combine(
|
||||||
repository.calendars()
|
writableCalendars,
|
||||||
.map { calendars -> calendars.filter { it.canModifyContents } }
|
|
||||||
.catch { emit(emptyList()) },
|
|
||||||
prefs.lastUsedCalendarId,
|
prefs.lastUsedCalendarId,
|
||||||
settingsPrefs.defaultFormFields,
|
settingsPrefs.defaultFormFields,
|
||||||
|
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
) { local, external ->
|
colorPalette,
|
||||||
|
) { local, external, palette ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
@@ -129,6 +160,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// the scope dialog drops "only this event" after a rule change.
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
recurrenceChanged = local.editTarget != null &&
|
recurrenceChanged = local.editTarget != null &&
|
||||||
resolved.rrule != local.editTarget.original.rrule,
|
resolved.rrule != local.editTarget.original.rrule,
|
||||||
|
colorPalette = palette,
|
||||||
|
allowColorOnUnsupportedCalendars = external.allowColorOnUnsupported,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -138,21 +171,26 @@ class EventEditViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise a fresh form for a new event on [date]. No-op when a form is
|
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||||
* already open, so user input survives configuration changes; [reset]
|
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||||
* clears it when the screen closes.
|
* in the day/week grid; without it the default is the next full hour (today)
|
||||||
|
* or 09:00 (any other day). No-op when a form is already open, so user input
|
||||||
|
* survives configuration changes; [reset] clears it when the screen closes.
|
||||||
*/
|
*/
|
||||||
fun openNew(date: LocalDate) {
|
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
||||||
if (_form.value != null) return
|
if (_form.value != null) return
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = TimeZone.currentSystemDefault()
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
val start = if (date == now.toLocalDateTime(zone).date) {
|
val start = when {
|
||||||
|
startMinutes != null ->
|
||||||
|
LocalDateTime(date, LocalTime(startMinutes / 60, startMinutes % 60))
|
||||||
|
date == now.toLocalDateTime(zone).date -> {
|
||||||
// Today: the next full hour (may roll into tomorrow before midnight).
|
// Today: the next full hour (may roll into tomorrow before midnight).
|
||||||
val hourMillis = 3_600_000L
|
val hourMillis = 3_600_000L
|
||||||
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||||
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||||
} else {
|
}
|
||||||
LocalDateTime(date, LocalTime(9, 0))
|
else -> LocalDateTime(date, LocalTime(9, 0))
|
||||||
}
|
}
|
||||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
@@ -202,10 +240,25 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
|
fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) }
|
||||||
fun setCalendar(id: Long) = update { it.copy(calendarId = id) }
|
|
||||||
|
/**
|
||||||
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
|
* The event falls back to the new calendar's colour until re-picked.
|
||||||
|
*/
|
||||||
|
fun setCalendar(id: Long) = update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||||
|
|
||||||
|
/** Pick a palette colour (round-trips via its key); [argb] is its swatch. */
|
||||||
|
fun setColorKey(key: String, argb: Int) = update { it.copy(colorKey = key, color = argb) }
|
||||||
|
|
||||||
|
/** Pick a raw colour (local / opted-in calendars), clearing any palette key. */
|
||||||
|
fun setColorRaw(argb: Int) = update { it.copy(colorKey = null, color = argb) }
|
||||||
|
|
||||||
|
/** Clear the colour so the event inherits its calendar's. */
|
||||||
|
fun clearColor() = update { it.copy(colorKey = null, color = null) }
|
||||||
|
|
||||||
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
/** Bare RRULE value from the recurrence picker; null = does not repeat. */
|
||||||
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
fun setRecurrence(rrule: String?) = update { it.copy(rrule = rrule) }
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,17 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.filter
|
package de.jeanlucmakiola.calendula.ui.filter
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
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
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -28,7 +20,9 @@ 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.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||||
@@ -53,66 +47,43 @@ fun CalendarFilterList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders as a plain (non-scrolling) [Column] so it flows inside the drawer's
|
||||||
|
* single scroll container — the whole sidebar scrolls as one. Calendar counts
|
||||||
|
* are small, so a lazy list isn't needed.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun FilterList(
|
private fun FilterList(
|
||||||
groups: List<AccountGroup>,
|
groups: List<AccountGroup>,
|
||||||
onSetVisible: (Long, Boolean) -> Unit,
|
onSetVisible: (Long, Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
LazyColumn(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
contentPadding = PaddingValues(vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
item(key = "header-${group.account}") {
|
|
||||||
Text(
|
Text(
|
||||||
text = group.account,
|
text = group.account,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||||
)
|
)
|
||||||
}
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
items(group.calendars, key = { it.id }) { cal ->
|
GroupedRow(
|
||||||
CalendarToggleRow(
|
title = cal.displayName,
|
||||||
row = cal,
|
position = positionOf(index, group.calendars.size),
|
||||||
dark = dark,
|
minHeight = 56.dp,
|
||||||
|
leading = { CalendarColorChip(cal.color) },
|
||||||
|
trailing = {
|
||||||
|
Checkbox(
|
||||||
|
checked = cal.visible,
|
||||||
onCheckedChange = { onSetVisible(cal.id, it) },
|
onCheckedChange = { onSetVisible(cal.id, it) },
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
}
|
onClick = { onSetVisible(cal.id, !cal.visible) },
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CalendarToggleRow(
|
|
||||||
row: CalendarRow,
|
|
||||||
dark: Boolean,
|
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 28.dp, vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(14.dp)
|
|
||||||
.background(pastelize(row.color, dark), CircleShape),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = row.displayName,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Checkbox(
|
|
||||||
checked = row.visible,
|
|
||||||
onCheckedChange = onCheckedChange,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FilterLoading(modifier: Modifier = Modifier) {
|
private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ fun MonthScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onCreateEvent: (LocalDate) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -166,6 +166,7 @@ fun MonthScreen(
|
|||||||
onCreateEvent(
|
onCreateEvent(
|
||||||
if (isOnCurrentMonth) today
|
if (isOnCurrentMonth) today
|
||||||
else LocalDate(month.year, month.month, 1),
|
else LocalDate(month.year, month.month, 1),
|
||||||
|
null,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,263 +1,456 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
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.core.net.toUri
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
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
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.Gavel
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
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.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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.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.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
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.ui.common.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
|
||||||
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
|
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings (M4) — appearance (theme, dynamic colour, week start), language,
|
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||||
* and an about section. A full-screen destination; [onBack] pops it.
|
* Neutral chips stay grey; accents are drawn from the M3 scheme so they adapt
|
||||||
|
* to theme, dark mode and dynamic colour.
|
||||||
|
*/
|
||||||
|
private enum class ChipAccent { Neutral, Primary, Tertiary }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings (M4), restructured in v2.3 into a category hub with sub-screens.
|
||||||
|
* Both the hub and the sub-screens use a collapsing [LargeTopAppBar] and the
|
||||||
|
* grouped-row card system. Calendars opens the separate manager hoisted in
|
||||||
|
* [CalendarHost]; Language opens an inline OptionCard dialog; About is a card
|
||||||
|
* at the top. A full-screen destination; [onBack] pops it.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onManageCalendars: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: SettingsViewModel = hiltViewModel(),
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
|
||||||
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
// Intercept the system back button/gesture — without this it falls through
|
Box(
|
||||||
// to the activity and closes the app instead of returning to the calendar.
|
|
||||||
BackHandler { onBack() }
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surface),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(stringResource(R.string.settings_title)) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.settings_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
) {
|
) {
|
||||||
SectionHeader(stringResource(R.string.settings_section_appearance))
|
SettingsHub(
|
||||||
|
onBack = onBack,
|
||||||
SettingDropdownRow(
|
onOpenSection = { section = it },
|
||||||
title = stringResource(R.string.settings_theme),
|
onManageCalendars = onManageCalendars,
|
||||||
selected = state.themeMode,
|
|
||||||
options = ThemeMode.entries,
|
|
||||||
optionLabel = { themeLabel(it) },
|
|
||||||
onSelect = viewModel::setThemeMode,
|
|
||||||
)
|
|
||||||
DynamicColorRow(
|
|
||||||
checked = state.dynamicColor,
|
|
||||||
enabled = state.dynamicColorAvailable,
|
|
||||||
onCheckedChange = viewModel::setDynamicColor,
|
|
||||||
)
|
|
||||||
SettingDropdownRow(
|
|
||||||
title = stringResource(R.string.settings_week_start),
|
|
||||||
selected = state.weekStart,
|
|
||||||
options = WeekStartPref.entries,
|
|
||||||
optionLabel = { weekStartLabel(it) },
|
|
||||||
onSelect = viewModel::setWeekStart,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
AnimatedVisibility(
|
||||||
SectionHeader(stringResource(R.string.settings_section_event_form))
|
visible = section == SettingsSection.Appearance,
|
||||||
Text(
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
text = stringResource(R.string.settings_form_fields_hint),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
}
|
||||||
)
|
AnimatedVisibility(
|
||||||
EventFormField.entries.forEach { field ->
|
visible = section == SettingsSection.EventForm,
|
||||||
FormFieldRow(
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
title = stringResource(formFieldLabel(field)),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
checked = field in state.defaultFormFields,
|
) {
|
||||||
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
|
EventFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
)
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = section == SettingsSection.Notifications,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
NotificationsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
// ---------------------------------------------------------------------------
|
||||||
SectionHeader(stringResource(R.string.settings_section_notifications))
|
// Hub
|
||||||
RemindersRow(
|
// ---------------------------------------------------------------------------
|
||||||
checked = state.remindersEnabled,
|
|
||||||
onCheckedChange = viewModel::setRemindersEnabled,
|
@Composable
|
||||||
|
private fun SettingsHub(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenSection: (SettingsSection) -> Unit,
|
||||||
|
onManageCalendars: () -> Unit,
|
||||||
|
) {
|
||||||
|
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
||||||
|
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
|
summary = stringResource(R.string.settings_appearance_subtitle),
|
||||||
|
position = Position.Top,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
|
||||||
|
onClick = { onOpenSection(SettingsSection.Appearance) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
|
summary = stringResource(R.string.settings_event_form_subtitle),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Tune, ChipAccent.Neutral) },
|
||||||
|
onClick = { onOpenSection(SettingsSection.EventForm) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
|
summary = stringResource(R.string.settings_notifications_subtitle),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
|
||||||
|
onClick = { onOpenSection(SettingsSection.Notifications) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_section_calendars),
|
||||||
|
summary = stringResource(R.string.settings_manage_calendars_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { CategoryIcon(Icons.Default.CalendarMonth, ChipAccent.Tertiary) },
|
||||||
|
onClick = onManageCalendars,
|
||||||
|
)
|
||||||
|
LanguageRow(position = Position.Bottom)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
AppVersionText()
|
||||||
SectionHeader(stringResource(R.string.settings_section_language))
|
|
||||||
LanguageRow()
|
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
|
||||||
SectionHeader(stringResource(R.string.settings_section_about))
|
|
||||||
AboutSection()
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LanguageRow() {
|
private fun LanguageRow(position: Position) {
|
||||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
// dropdown updates instantly even before the recreation lands.
|
// row updates instantly even before the recreation lands.
|
||||||
var current by remember { mutableStateOf(AppLanguage.current()) }
|
var current by remember { mutableStateOf(AppLanguage.current()) }
|
||||||
SettingDropdownRow(
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_language),
|
||||||
|
summary = languageLabel(current),
|
||||||
|
position = position,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
||||||
|
onClick = { showDialog = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
OptionPickerDialog(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
selected = current,
|
|
||||||
options = LanguagePref.entries,
|
options = LanguagePref.entries,
|
||||||
optionLabel = { languageLabel(it) },
|
selected = current,
|
||||||
|
label = { languageLabel(it) },
|
||||||
onSelect = {
|
onSelect = {
|
||||||
current = it
|
current = it
|
||||||
AppLanguage.apply(it)
|
AppLanguage.apply(it)
|
||||||
},
|
},
|
||||||
|
onDismiss = { showDialog = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
private fun AboutCard() {
|
||||||
Text(
|
val context = LocalContext.current
|
||||||
text = text,
|
val sourceUrl = stringResource(R.string.about_source_url)
|
||||||
style = MaterialTheme.typography.labelLarge,
|
val licenseUrl = stringResource(R.string.about_license_url)
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
Surface(
|
||||||
private fun <T> SettingDropdownRow(
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
title: String,
|
shape = RoundedCornerShape(24.dp),
|
||||||
selected: T,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
options: List<T>,
|
|
||||||
optionLabel: @Composable (T) -> String,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
Column(
|
||||||
Box {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { expanded = true }
|
.padding(16.dp),
|
||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
AppLogo()
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = stringResource(R.string.app_name),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = optionLabel(selected),
|
text = stringResource(R.string.settings_about_author),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Icon(
|
|
||||||
Icons.Default.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
}
|
||||||
options.forEach { option ->
|
Spacer(Modifier.height(12.dp))
|
||||||
DropdownMenuItem(
|
Row(
|
||||||
text = { Text(optionLabel(option)) },
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = {
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
expanded = false
|
) {
|
||||||
onSelect(option)
|
OutlinedButton(
|
||||||
},
|
onClick = { openUrl(context, sourceUrl) },
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_gitea),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.settings_about_source))
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { openUrl(context, licenseUrl) },
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Gavel,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.settings_license))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Plain centred version mark at the foot of the settings list (no card). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DynamicColorRow(
|
private fun AppVersionText() {
|
||||||
checked: Boolean,
|
val context = LocalContext.current
|
||||||
enabled: Boolean,
|
val versionName = remember {
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
runCatching {
|
||||||
) {
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
Row(
|
}.getOrNull() ?: "—"
|
||||||
modifier = Modifier
|
}
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.settings_dynamic_color),
|
text = stringResource(R.string.settings_about_version, versionName),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = if (enabled) MaterialTheme.colorScheme.onSurface
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
if (!enabled) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_dynamic_color_unavailable),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app icon as a rounded chip: the off-white launcher mark over its slate
|
||||||
|
* background colour, rendered oversized and clipped to fill the chip the way a
|
||||||
|
* launcher mask would.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AppLogo() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.settings_about_logo_desc),
|
||||||
|
modifier = Modifier.requiredSize(108.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-screens
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppearanceScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
|
var showWeekStart by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
|
onBack = onBack,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_theme),
|
||||||
|
summary = themeLabel(state.themeMode),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showTheme = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_dynamic_color),
|
||||||
|
summary = if (state.dynamicColorAvailable) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||||
|
},
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.dynamicColor,
|
||||||
|
onCheckedChange = viewModel::setDynamicColor,
|
||||||
|
enabled = state.dynamicColorAvailable,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = if (state.dynamicColorAvailable) {
|
||||||
|
{ viewModel.setDynamicColor(!state.dynamicColor) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_week_start),
|
||||||
|
summary = weekStartLabel(state.weekStart),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showWeekStart = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showTheme) {
|
||||||
|
OptionPickerDialog(
|
||||||
|
title = stringResource(R.string.settings_theme),
|
||||||
|
options = ThemeMode.entries,
|
||||||
|
selected = state.themeMode,
|
||||||
|
label = { themeLabel(it) },
|
||||||
|
onSelect = viewModel::setThemeMode,
|
||||||
|
onDismiss = { showTheme = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showWeekStart) {
|
||||||
|
OptionPickerDialog(
|
||||||
|
title = stringResource(R.string.settings_week_start),
|
||||||
|
options = WeekStartPref.entries,
|
||||||
|
selected = state.weekStart,
|
||||||
|
label = { weekStartLabel(it) },
|
||||||
|
onSelect = viewModel::setWeekStart,
|
||||||
|
onDismiss = { showWeekStart = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventFormScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
|
onBack = onBack,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_form_fields_hint),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
val fields = EventFormField.entries
|
||||||
|
fields.forEachIndexed { index, field ->
|
||||||
|
val checked = field in state.defaultFormFields
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(formFieldLabel(field)),
|
||||||
|
position = positionOf(index, fields.size),
|
||||||
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
onCheckedChange = onCheckedChange,
|
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
|
||||||
enabled = enabled,
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setFormFieldDefault(field, !checked) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-event colour on calendars that publish no colour set (some
|
||||||
|
// CalDAV) — off by default, with the honest caveat that the colour may
|
||||||
|
// not survive their next sync. Local and palette calendars ignore it.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_color_unsupported),
|
||||||
|
summary = stringResource(R.string.settings_color_unsupported_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.allowColorOnUnsupportedCalendars,
|
||||||
|
onCheckedChange = { viewModel.setAllowColorOnUnsupportedCalendars(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
viewModel.setAllowColorOnUnsupportedCalendars(
|
||||||
|
!state.allowColorOnUnsupportedCalendars,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,36 +461,17 @@ private fun DynamicColorRow(
|
|||||||
* the pref is set either way; the OS permission is the real gate.
|
* the pref is set either way; the OS permission is the real gate.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun RemindersRow(
|
private fun NotificationsScreen(
|
||||||
checked: Boolean,
|
state: SettingsUiState,
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
|
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
|
||||||
Row(
|
val toggleReminders: (Boolean) -> Unit = { enabled ->
|
||||||
modifier = Modifier
|
viewModel.setRemindersEnabled(enabled)
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_reminders),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_reminders_hint),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Switch(
|
|
||||||
checked = checked,
|
|
||||||
onCheckedChange = { enabled ->
|
|
||||||
onCheckedChange(enabled)
|
|
||||||
val needsPermission = enabled &&
|
val needsPermission = enabled &&
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
@@ -306,96 +480,93 @@ private fun RemindersRow(
|
|||||||
if (needsPermission) {
|
if (needsPermission) {
|
||||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
|
onBack = onBack,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_reminders),
|
||||||
|
summary = stringResource(R.string.settings_reminders_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = state.remindersEnabled, onCheckedChange = toggleReminders)
|
||||||
|
},
|
||||||
|
onClick = { toggleReminders(!state.remindersEnabled) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared building blocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leading circular icon chip. Colours come from the M3 scheme via a container /
|
||||||
|
* on-container token pair, so each accent stays correctly paired across theme,
|
||||||
|
* dark mode and dynamic colour.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val (background, iconColor) = when (accent) {
|
||||||
|
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
|
||||||
|
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
|
||||||
|
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(background),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = iconColor,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OptionCard selection dialog — the app's only sanctioned picker style. */
|
||||||
|
@Composable
|
||||||
|
private fun <T> OptionPickerDialog(
|
||||||
|
title: String,
|
||||||
|
options: List<T>,
|
||||||
|
selected: T,
|
||||||
|
label: @Composable (T) -> String,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
options.forEach { option ->
|
||||||
|
OptionCard(
|
||||||
|
label = label(option),
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
selected = option == selected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
private fun openUrl(context: Context, url: String) {
|
||||||
private fun AboutSection() {
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
val context = LocalContext.current
|
|
||||||
val versionName = remember {
|
|
||||||
runCatching {
|
|
||||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
|
||||||
}.getOrNull() ?: "—"
|
|
||||||
}
|
|
||||||
val sourceUrl = stringResource(R.string.about_source_url)
|
|
||||||
|
|
||||||
AboutRow(
|
|
||||||
title = stringResource(R.string.settings_version),
|
|
||||||
value = versionName,
|
|
||||||
)
|
|
||||||
AboutRow(
|
|
||||||
title = stringResource(R.string.settings_license),
|
|
||||||
value = stringResource(R.string.settings_license_value),
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f).padding(start = 8.dp)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_source),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = sourceUrl.removePrefix("https://"),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TextButton(onClick = {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW, sourceUrl.toUri())
|
|
||||||
runCatching { context.startActivity(intent) }
|
runCatching { context.startActivity(intent) }
|
||||||
}) {
|
|
||||||
Text(stringResource(R.string.settings_source_open))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AboutRow(title: String, value: String) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FormFieldRow(
|
|
||||||
title: String,
|
|
||||||
checked: Boolean,
|
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
||||||
@@ -405,6 +576,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -20,4 +20,9 @@ data class SettingsUiState(
|
|||||||
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). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
|
/**
|
||||||
|
* Whether the event-colour picker is offered on calendars that publish no
|
||||||
|
* colour palette (the colour may then not survive their next sync).
|
||||||
|
*/
|
||||||
|
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
|
||||||
val state: StateFlow<SettingsUiState> =
|
val state: StateFlow<SettingsUiState> =
|
||||||
|
combine(
|
||||||
|
// combine() only types up to five flows, so the sixth pref folds
|
||||||
|
// into the assembled state in an outer combine.
|
||||||
combine(
|
combine(
|
||||||
prefs.themeMode,
|
prefs.themeMode,
|
||||||
prefs.dynamicColor,
|
prefs.dynamicColor,
|
||||||
@@ -38,6 +41,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
defaultFormFields = formFields,
|
defaultFormFields = formFields,
|
||||||
remindersEnabled = reminders,
|
remindersEnabled = reminders,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
prefs.allowColorOnUnsupportedCalendars,
|
||||||
|
) { base, allowColor ->
|
||||||
|
base.copy(allowColorOnUnsupportedCalendars = allowColor)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
@@ -63,4 +70,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
fun setRemindersEnabled(enabled: Boolean) {
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setAllowColorOnUnsupportedCalendars(enabled) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateDpAsState
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -112,7 +113,7 @@ fun WeekScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onCreateEvent: (LocalDate) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -194,7 +195,7 @@ fun WeekScreen(
|
|||||||
// Anchor on today when it's in view, else the week's first day.
|
// Anchor on today when it's in view, else the week's first day.
|
||||||
val today = Clock.System.now()
|
val today = Clock.System.now()
|
||||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
onCreateEvent(if (isOnCurrentWeek) today else weekStart)
|
onCreateEvent(if (isOnCurrentWeek) today else weekStart, null)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -207,6 +208,7 @@ fun WeekScreen(
|
|||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
@@ -224,6 +226,7 @@ private fun WeekContent(
|
|||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -295,6 +298,7 @@ private fun WeekContent(
|
|||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,6 +311,7 @@ private fun WeekSuccess(
|
|||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -320,7 +325,12 @@ private fun WeekSuccess(
|
|||||||
// Breathing room between the (colour-shifting) top section and the
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
// scrolling timeline below.
|
// scrolling timeline below.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(state = state, scrollState = scrollState, onEventClick = onEventClick)
|
Timeline(
|
||||||
|
state = state,
|
||||||
|
scrollState = scrollState,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,6 +543,7 @@ private fun Timeline(
|
|||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -588,7 +599,9 @@ private fun Timeline(
|
|||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timedByDay[day].orEmpty(),
|
blocks = state.timedByDay[day].orEmpty(),
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
date = day,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onCreateAt = onCreateAt,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
@@ -604,9 +617,12 @@ private fun Timeline(
|
|||||||
private fun DayColumnCard(
|
private fun DayColumnCard(
|
||||||
blocks: List<TimedBlock>,
|
blocks: List<TimedBlock>,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
date: LocalDate,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
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.
|
||||||
@@ -616,7 +632,18 @@ private fun DayColumnCard(
|
|||||||
),
|
),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Tap an empty slot to create an event there; taps on event
|
||||||
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
|
.pointerInput(date) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
||||||
|
onCreateAt(date, hour * 60)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
val colWidth = maxWidth
|
val colWidth = maxWidth
|
||||||
blocks.forEach { block ->
|
blocks.forEach { block ->
|
||||||
val laneWidth = colWidth / block.laneCount
|
val laneWidth = colWidth / block.laneCount
|
||||||
|
|||||||
16
app/src/main/res/drawable/ic_gitea.xml
Normal file
16
app/src/main/res/drawable/ic_gitea.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Gitea brand mark, used on the "Source" button in Settings → About.
|
||||||
|
Single-path logo from Simple Icons (https://simpleicons.org, CC0),
|
||||||
|
pathData kept verbatim so Android's PathParser reads the arc flags.
|
||||||
|
fillColor is a placeholder; the Compose Icon recolours it via tint.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z" />
|
||||||
|
</vector>
|
||||||
@@ -82,6 +82,15 @@
|
|||||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||||
<string name="event_edit_visibility">Sichtbarkeit</string>
|
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||||
|
|
||||||
|
<!-- Termin-Formular — eigene Terminfarbe -->
|
||||||
|
<string name="event_edit_color">Farbe</string>
|
||||||
|
<string name="event_edit_color_default">Kalenderfarbe</string>
|
||||||
|
<string name="event_edit_color_custom">Eigene Farbe</string>
|
||||||
|
<string name="event_edit_color_reset">Zurücksetzen</string>
|
||||||
|
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
|
||||||
|
|
||||||
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
||||||
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
||||||
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
||||||
@@ -208,18 +217,46 @@
|
|||||||
<string name="settings_week_start_sunday">Sonntag</string>
|
<string name="settings_week_start_sunday">Sonntag</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_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
|
||||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
<string name="settings_reminders">Termin-Erinnerungen</string>
|
<string name="settings_reminders">Termin-Erinnerungen</string>
|
||||||
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
<string name="settings_reminders_hint">Erinnerungen doppelt? Eine andere Kalender-App zeigt sie ebenfalls — schalte sie in einer der beiden ab.</string>
|
||||||
|
<string name="settings_section_calendars">Kalender</string>
|
||||||
|
<string name="settings_manage_calendars">Kalender verwalten</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Lokale Kalender anlegen, synchronisierte verwalten</string>
|
||||||
<string name="settings_section_language">Sprache</string>
|
<string name="settings_section_language">Sprache</string>
|
||||||
<string name="settings_language">App-Sprache</string>
|
<string name="settings_language">App-Sprache</string>
|
||||||
<string name="settings_language_auto">Systemstandard</string>
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
<string name="settings_language_german">Deutsch</string>
|
<string name="settings_language_german">Deutsch</string>
|
||||||
<string name="settings_language_english">English</string>
|
<string name="settings_language_english">English</string>
|
||||||
|
<!-- Hub category subtitles -->
|
||||||
|
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
||||||
|
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||||
|
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
||||||
<string name="settings_section_about">Über</string>
|
<string name="settings_section_about">Über</string>
|
||||||
<string name="settings_version">Version</string>
|
|
||||||
<string name="settings_license">Lizenz</string>
|
<string name="settings_license">Lizenz</string>
|
||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
<string name="settings_source">Quellcode</string>
|
<string name="settings_about_author">von Jean-Luc Makiola</string>
|
||||||
<string name="settings_source_open">Öffnen</string>
|
<string name="settings_about_source">Quellcode</string>
|
||||||
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||||
|
|
||||||
|
<!-- Calendar manager -->
|
||||||
|
<string name="calendars_title">Kalender</string>
|
||||||
|
<string name="calendars_local_header">Deine Kalender</string>
|
||||||
|
<string name="calendars_local_empty">Noch keine lokalen Kalender. Lege einen an, um Termine nur auf diesem Gerät zu speichern.</string>
|
||||||
|
<string name="calendars_add">Kalender hinzufügen</string>
|
||||||
|
<string name="calendars_synced_header">Synchronisierte Kalender</string>
|
||||||
|
<string name="calendars_synced_hint">Diese stammen von Konten auf deinem Gerät. Erstelle und bearbeite sie in der jeweiligen App.</string>
|
||||||
|
<string name="calendars_manage_in_app">Verwalten</string>
|
||||||
|
<string name="calendars_add_account">Konto hinzufügen</string>
|
||||||
|
<string name="calendars_new_title">Neuer Kalender</string>
|
||||||
|
<string name="calendars_edit_title">Kalender bearbeiten</string>
|
||||||
|
<string name="calendars_name_label">Name</string>
|
||||||
|
<string name="calendars_color_label">Farbe</string>
|
||||||
|
<string name="calendars_description_hint">Beschreibung hinzufügen</string>
|
||||||
|
<string name="calendars_delete_confirm_title">Kalender löschen?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" und alle zugehörigen Termine werden dauerhaft von diesem Gerät entfernt.</string>
|
||||||
|
<string name="calendars_write_error">Änderung konnte nicht gespeichert werden.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -83,6 +83,15 @@
|
|||||||
<string name="event_edit_availability">Availability</string>
|
<string name="event_edit_availability">Availability</string>
|
||||||
<string name="event_edit_visibility">Visibility</string>
|
<string name="event_edit_visibility">Visibility</string>
|
||||||
|
|
||||||
|
<!-- Event form — per-event color -->
|
||||||
|
<string name="event_edit_color">Color</string>
|
||||||
|
<string name="event_edit_color_default">Calendar color</string>
|
||||||
|
<string name="event_edit_color_custom">Custom color</string>
|
||||||
|
<string name="event_edit_color_reset">Reset</string>
|
||||||
|
<string name="event_edit_color_unsupported">Not available for this calendar</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">This calendar publishes no color set. You can allow custom colors for such calendars in Settings.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">This calendar may drop or overwrite the color on its next sync.</string>
|
||||||
|
|
||||||
<!-- Event form — save conflict (v2.0) -->
|
<!-- Event form — save conflict (v2.0) -->
|
||||||
<string name="event_edit_conflict_title">Event changed elsewhere</string>
|
<string name="event_edit_conflict_title">Event changed elsewhere</string>
|
||||||
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
|
<string name="event_edit_conflict_body">While you were editing, this event was changed — by sync or another app. What should happen to your changes?</string>
|
||||||
@@ -209,19 +218,48 @@
|
|||||||
<string name="settings_week_start_sunday">Sunday</string>
|
<string name="settings_week_start_sunday">Sunday</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_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
<string name="settings_reminders">Event reminders</string>
|
<string name="settings_reminders">Event reminders</string>
|
||||||
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
<string name="settings_reminders_hint">Seeing reminders twice? Another calendar app is posting them too — turn them off in one of the two.</string>
|
||||||
|
<string name="settings_section_calendars">Calendars</string>
|
||||||
|
<string name="settings_manage_calendars">Manage calendars</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Create local calendars; manage synced ones</string>
|
||||||
<string name="settings_section_language">Language</string>
|
<string name="settings_section_language">Language</string>
|
||||||
<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>
|
||||||
<string name="settings_language_german">Deutsch</string>
|
<string name="settings_language_german">Deutsch</string>
|
||||||
<string name="settings_language_english">English</string>
|
<string name="settings_language_english">English</string>
|
||||||
|
<!-- Hub category subtitles -->
|
||||||
|
<string name="settings_appearance_subtitle">Theme, dynamic colour, week start</string>
|
||||||
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
<string name="settings_section_about">About</string>
|
<string name="settings_section_about">About</string>
|
||||||
<string name="settings_version">Version</string>
|
|
||||||
<string name="settings_license">License</string>
|
<string name="settings_license">License</string>
|
||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
<string name="settings_source">Source code</string>
|
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||||
<string name="settings_source_open">Open</string>
|
<string name="settings_about_source">Source</string>
|
||||||
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Calendula app icon</string>
|
||||||
|
|
||||||
|
<!-- Calendar manager -->
|
||||||
|
<string name="calendars_title">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_add">Add calendar</string>
|
||||||
|
<string name="calendars_synced_header">Synced calendars</string>
|
||||||
|
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||||
|
<string name="calendars_manage_in_app">Manage</string>
|
||||||
|
<string name="calendars_add_account">Add account</string>
|
||||||
|
<string name="calendars_new_title">New calendar</string>
|
||||||
|
<string name="calendars_edit_title">Edit calendar</string>
|
||||||
|
<string name="calendars_name_label">Name</string>
|
||||||
|
<string name="calendars_color_label">Color</string>
|
||||||
|
<string name="calendars_description_hint">Add a description</string>
|
||||||
|
<string name="calendars_delete_confirm_title">Delete calendar?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" and all of its events will be permanently removed from this device.</string>
|
||||||
|
<string name="calendars_write_error">Couldn\'t save the change.</string>
|
||||||
<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>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class CalendarMapperTest {
|
|||||||
color: Int = 0,
|
color: Int = 0,
|
||||||
visible: Int = 1,
|
visible: Int = 1,
|
||||||
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||||
|
description: String? = null,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
CalendarProjection.IDX_ID to id,
|
CalendarProjection.IDX_ID to id,
|
||||||
CalendarProjection.IDX_DISPLAY_NAME to displayName,
|
CalendarProjection.IDX_DISPLAY_NAME to displayName,
|
||||||
@@ -22,6 +23,7 @@ class CalendarMapperTest {
|
|||||||
CalendarProjection.IDX_COLOR to color,
|
CalendarProjection.IDX_COLOR to color,
|
||||||
CalendarProjection.IDX_VISIBLE to visible,
|
CalendarProjection.IDX_VISIBLE to visible,
|
||||||
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
|
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||||
|
CalendarProjection.IDX_DESCRIPTION to description,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -90,4 +92,35 @@ class CalendarMapperTest {
|
|||||||
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE)
|
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE)
|
||||||
assertThat(src.toCalendarSource().canModifyContents).isFalse()
|
assertThat(src.toCalendarSource().canModifyContents).isFalse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `local account type marks the calendar as app-owned`() {
|
||||||
|
val src = reader(accountType = CalendarContract.ACCOUNT_TYPE_LOCAL).toCalendarSource()
|
||||||
|
assertThat(src.isLocal).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `synced account type is not local`() {
|
||||||
|
val src = reader(accountType = "com.google").toCalendarSource()
|
||||||
|
assertThat(src.isLocal).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `local calendar exposes its CAL_SYNC1 description`() {
|
||||||
|
val src = reader(
|
||||||
|
accountType = CalendarContract.ACCOUNT_TYPE_LOCAL,
|
||||||
|
description = "House stuff",
|
||||||
|
).toCalendarSource()
|
||||||
|
assertThat(src.description).isEqualTo("House stuff")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `synced calendar never exposes CAL_SYNC1 as a description`() {
|
||||||
|
// CAL_SYNC1 holds the sync token for synced rows — it must not leak as a note.
|
||||||
|
val src = reader(
|
||||||
|
accountType = "com.google",
|
||||||
|
description = """{"type":"SYNC_TOKEN","value":"…"}""",
|
||||||
|
).toCalendarSource()
|
||||||
|
assertThat(src.description).isNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import app.cash.turbine.test
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
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 kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -327,6 +328,65 @@ class CalendarRepositoryImplTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createLocalCalendar delegates and returns the new id`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply { nextCalendarId = 501L }
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
val id = repo.createLocalCalendar(
|
||||||
|
displayName = "Home",
|
||||||
|
color = 0xFF33B679.toInt(),
|
||||||
|
description = "House stuff",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(id).isEqualTo(501L)
|
||||||
|
assertThat(fake.createdCalendars).containsExactly(
|
||||||
|
FakeCalendarDataSource.CreatedCalendar("Home", 0xFF33B679.toInt(), "House stuff"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `updateCalendar forwards id, name, color and description`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource()
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
repo.updateCalendar(
|
||||||
|
id = 5L,
|
||||||
|
displayName = "Renamed",
|
||||||
|
color = 0xFF039BE5.toInt(),
|
||||||
|
description = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(fake.updatedCalendars).containsExactly(
|
||||||
|
FakeCalendarDataSource.UpdatedCalendar(5L, "Renamed", 0xFF039BE5.toInt(), null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `deleteCalendar delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource()
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
repo.deleteCalendar(id = 7L)
|
||||||
|
|
||||||
|
assertThat(fake.deletedCalendarIds).containsExactly(7L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `createLocalCalendar propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
writeError = WriteFailedException("create local calendar 'Home'")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repo.createLocalCalendar(displayName = "Home", color = 0, description = null)
|
||||||
|
error("Expected WriteFailedException")
|
||||||
|
} catch (expected: WriteFailedException) {
|
||||||
|
assertThat(expected.message).contains("Home")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
|
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
@@ -341,4 +401,20 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(expected.message).contains("999")
|
assertThat(expected.message).contains("999")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `eventColorPalette delegates to the data source for the given calendar`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
eventColorPaletteResult = { id ->
|
||||||
|
if (id == 7L) listOf(EventColorOption("5", 0xFF33B679.toInt())) else emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||||
|
|
||||||
|
assertThat(repo.eventColorPalette(7L))
|
||||||
|
.containsExactly(EventColorOption("5", 0xFF33B679.toInt()))
|
||||||
|
assertThat(repo.eventColorPalette(8L)).isEmpty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class EventDetailMapperTest {
|
|||||||
organizer: String? = "x@y",
|
organizer: String? = "x@y",
|
||||||
rrule: String? = null,
|
rrule: String? = null,
|
||||||
eventColor: Any? = null,
|
eventColor: Any? = null,
|
||||||
|
eventColorKey: String? = null,
|
||||||
calendarColor: Int = 0xFFAABBCC.toInt(),
|
calendarColor: Int = 0xFFAABBCC.toInt(),
|
||||||
dtstart: Long = 1_000_000_000L,
|
dtstart: Long = 1_000_000_000L,
|
||||||
dtend: Long = 1_000_003_600L,
|
dtend: Long = 1_000_003_600L,
|
||||||
@@ -49,6 +50,7 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
|
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||||
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
|
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun attendeeReader(
|
private fun attendeeReader(
|
||||||
@@ -99,6 +101,22 @@ class EventDetailMapperTest {
|
|||||||
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
||||||
.toDetail()
|
.toDetail()
|
||||||
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
||||||
|
// No own colour: the edit form must see this as "inherits".
|
||||||
|
assertThat(detail.eventColor).isNull()
|
||||||
|
assertThat(detail.eventColorKey).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `own event color and key are surfaced apart from the resolved color`() {
|
||||||
|
val detail = detailReader(
|
||||||
|
eventColor = 0xFF33B679.toInt(),
|
||||||
|
eventColorKey = "5",
|
||||||
|
calendarColor = 0xFF112233.toInt(),
|
||||||
|
).toDetail()
|
||||||
|
// Resolved display colour is the event's own, not the calendar fallback.
|
||||||
|
assertThat(detail!!.instance.color).isEqualTo(0xFF33B679.toInt())
|
||||||
|
assertThat(detail.eventColor).isEqualTo(0xFF33B679.toInt())
|
||||||
|
assertThat(detail.eventColorKey).isEqualTo("5")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -218,4 +218,83 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
||||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- per-event colour ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `palette colour writes only the key, never a raw colour`() {
|
||||||
|
assertThat(eventColorColumns(colorKey = "5", color = 0xFF33B679.toInt()))
|
||||||
|
.containsExactly(CalendarContract.Events.EVENT_COLOR_KEY, "5")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `raw colour writes the colour and clears any key`() {
|
||||||
|
assertThat(eventColorColumns(colorKey = null, color = 0xFF8E24AA.toInt()))
|
||||||
|
.containsExactly(
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY, null,
|
||||||
|
CalendarContract.Events.EVENT_COLOR, 0xFF8E24AA.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no colour clears both columns so the event inherits its calendar`() {
|
||||||
|
assertThat(eventColorColumns(colorKey = null, color = null))
|
||||||
|
.containsExactly(
|
||||||
|
CalendarContract.Events.EVENT_COLOR_KEY, null,
|
||||||
|
CalendarContract.Events.EVENT_COLOR, null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setting a palette colour on update writes just the key`() {
|
||||||
|
val original = form()
|
||||||
|
val values = update(original, original.copy(colorKey = "3", color = 0xFFF6BF26.toInt()))
|
||||||
|
assertThat(values).containsExactly(CalendarContract.Events.EVENT_COLOR_KEY, "3")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `setting a raw colour on update writes the colour and a null key`() {
|
||||||
|
val original = form()
|
||||||
|
val values = update(original, original.copy(color = 0xFF039BE5.toInt()))
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, 0xFF039BE5.toInt())
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearing a colour on update writes explicit nulls`() {
|
||||||
|
val original = form().copy(color = 0xFFD50000.toInt())
|
||||||
|
val values = update(original, original.copy(color = null))
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unchanged colour writes no colour columns`() {
|
||||||
|
val original = form().copy(colorKey = "7", color = 0xFF3F51B5.toInt())
|
||||||
|
val values = update(original, original.copy(title = "Renamed"))
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `occurrence exception carries the palette key`() {
|
||||||
|
val values = buildOccurrenceExceptionValues(
|
||||||
|
form = form().copy(colorKey = "2", color = 0xFFE67C00.toInt()),
|
||||||
|
originalInstanceMillis = 0L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, "2")
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `occurrence exception with no colour clears both columns`() {
|
||||||
|
val values = buildOccurrenceExceptionValues(
|
||||||
|
form = form(),
|
||||||
|
originalInstanceMillis = 0L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -14,6 +15,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
var calendarsResult: List<CalendarSource> = emptyList()
|
var calendarsResult: List<CalendarSource> = emptyList()
|
||||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
|
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||||
/** Set to make the next write call throw. */
|
/** Set to make the next write call throw. */
|
||||||
var writeError: Exception? = null
|
var writeError: Exception? = null
|
||||||
/** Id returned by the next [insertEvent]. */
|
/** Id returned by the next [insertEvent]. */
|
||||||
@@ -26,6 +28,18 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
val deletedEventIds = mutableListOf<Long>()
|
val deletedEventIds = mutableListOf<Long>()
|
||||||
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
|
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
|
||||||
val deletedFromOccurrences = mutableListOf<Pair<Long, Long>>()
|
val deletedFromOccurrences = mutableListOf<Pair<Long, Long>>()
|
||||||
|
/** Id returned by the next [createLocalCalendar]. */
|
||||||
|
var nextCalendarId: Long = 500L
|
||||||
|
data class CreatedCalendar(val displayName: String, val color: Int, val description: String?)
|
||||||
|
data class UpdatedCalendar(
|
||||||
|
val id: Long,
|
||||||
|
val displayName: String,
|
||||||
|
val color: Int,
|
||||||
|
val description: String?,
|
||||||
|
)
|
||||||
|
val createdCalendars = mutableListOf<CreatedCalendar>()
|
||||||
|
val updatedCalendars = mutableListOf<UpdatedCalendar>()
|
||||||
|
val deletedCalendarIds = mutableListOf<Long>()
|
||||||
|
|
||||||
private val listeners = mutableListOf<() -> Unit>()
|
private val listeners = mutableListOf<() -> Unit>()
|
||||||
|
|
||||||
@@ -33,6 +47,24 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||||
instancesResult(beginMillis, endMillis)
|
instancesResult(beginMillis, endMillis)
|
||||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||||
|
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
eventColorPaletteResult(calendarId)
|
||||||
|
|
||||||
|
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
createdCalendars += CreatedCalendar(displayName, color, description)
|
||||||
|
return nextCalendarId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
updatedCalendars += UpdatedCalendar(id, displayName, color, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteCalendar(id: Long) {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
deletedCalendarIds += id
|
||||||
|
}
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm): Long {
|
override fun insertEvent(form: EventForm): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ class EventFormTest {
|
|||||||
rowStart: Long = 0L,
|
rowStart: Long = 0L,
|
||||||
rowEnd: Long = 0L,
|
rowEnd: Long = 0L,
|
||||||
attendees: List<Attendee> = emptyList(),
|
attendees: List<Attendee> = emptyList(),
|
||||||
|
eventColor: Int? = null,
|
||||||
|
eventColorKey: String? = null,
|
||||||
): EventDetail = EventDetail(
|
): EventDetail = EventDetail(
|
||||||
instance = EventInstance(
|
instance = EventInstance(
|
||||||
instanceId = 1L,
|
instanceId = 1L,
|
||||||
@@ -134,6 +136,8 @@ class EventFormTest {
|
|||||||
reminders = reminders,
|
reminders = reminders,
|
||||||
availability = availability,
|
availability = availability,
|
||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
|
eventColor = eventColor,
|
||||||
|
eventColorKey = eventColorKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -227,6 +231,7 @@ class EventFormTest {
|
|||||||
rrule = "FREQ=DAILY",
|
rrule = "FREQ=DAILY",
|
||||||
availability = Availability.Free,
|
availability = Availability.Free,
|
||||||
accessLevel = AccessLevel.Private,
|
accessLevel = AccessLevel.Private,
|
||||||
|
color = 0xFFD50000.toInt(),
|
||||||
)
|
)
|
||||||
assertThat(full.populatedFields()).containsExactly(
|
assertThat(full.populatedFields()).containsExactly(
|
||||||
EventFormField.Location,
|
EventFormField.Location,
|
||||||
@@ -235,6 +240,33 @@ class EventFormTest {
|
|||||||
EventFormField.Recurrence,
|
EventFormField.Recurrence,
|
||||||
EventFormField.Availability,
|
EventFormField.Availability,
|
||||||
EventFormField.Visibility,
|
EventFormField.Visibility,
|
||||||
|
EventFormField.Color,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm carries a palette colour as key plus swatch`() {
|
||||||
|
val prefilled = detail(eventColor = 0xFF33B679.toInt(), eventColorKey = "5")
|
||||||
|
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||||
|
assertThat(prefilled.colorKey).isEqualTo("5")
|
||||||
|
assertThat(prefilled.color).isEqualTo(0xFF33B679.toInt())
|
||||||
|
assertThat(prefilled.populatedFields()).contains(EventFormField.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm carries a raw colour with no key`() {
|
||||||
|
val prefilled = detail(eventColor = 0xFF8E24AA.toInt())
|
||||||
|
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||||
|
assertThat(prefilled.colorKey).isNull()
|
||||||
|
assertThat(prefilled.color).isEqualTo(0xFF8E24AA.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm leaves an inheriting event without a colour`() {
|
||||||
|
val prefilled = detail()
|
||||||
|
.toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||||
|
assertThat(prefilled.colorKey).isNull()
|
||||||
|
assertThat(prefilled.color).isNull()
|
||||||
|
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Color)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user