Compare commits
198 Commits
79ad70e0c0
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7602f158a3 | |||
| 052335e842 | |||
| 28fb7404d8 | |||
| 6ab235e1d8 | |||
| fb90c94e37 | |||
| 7a05fc3ac1 | |||
| bf38dac5d7 | |||
| 1e9581528e | |||
| 279872aa3b | |||
| ff6c4b6a4d | |||
| 7fb01ab8d1 | |||
| bdb16d069e | |||
| beb8536e8a | |||
| 63ba69729e | |||
| 01a6c8bab8 | |||
| f70b33b864 | |||
| 0df420e551 | |||
| 488de490f9 | |||
| f15fffa799 | |||
| 1499b5803d | |||
| 39cc70be95 | |||
| f9619e74d9 | |||
| e691336b5e | |||
| 4cee160c12 | |||
| a7ae8ff912 | |||
| d23a1d5b6d | |||
| b417900ddd | |||
| 95f4d28bbb | |||
| 24b005126e | |||
| ee9d65068d | |||
| 6e3d10858f | |||
| 94a82c2f6c | |||
| 81dcbbdce7 | |||
| becb9a6710 | |||
| 542744e342 | |||
| 7510e1f9af | |||
| 498250650c | |||
| ada6976e85 | |||
| 6a454e0b33 | |||
| cd6ae31d0f | |||
| 463811056d | |||
| 98d76deee5 | |||
| 20e18768eb | |||
| a34f29bcf8 | |||
| 997296e21e | |||
| 94355bf340 | |||
| 722fc87259 | |||
| 30fcbfa59f | |||
| c956ac79ed | |||
| 7d357bad87 | |||
| 3ffe76412e | |||
| 91c4a84818 | |||
| ccd433fee1 | |||
| 03f6b7c8c1 | |||
| 426ddf27ee | |||
| 4148196a36 | |||
| 9a1d753abc | |||
| 1b29abed17 | |||
| 85d72ad051 | |||
| fd3bcdf2f7 | |||
| 46afa830b3 | |||
| 7de9b2f81b | |||
| 3ec46c8631 | |||
| 529dc9c374 | |||
| 60ef920b82 | |||
| 7b7b859fee | |||
| b5c2930609 | |||
| 3feafe38f2 | |||
| 19baed9291 | |||
| 4ea68adf8b | |||
| c3ba8ccf64 | |||
| 7ed1d89b66 | |||
| 734bcae02d | |||
| 629896b74d | |||
| 01ac61185b | |||
| ca01f6e729 | |||
| 0588609c75 | |||
| ab3df639b9 | |||
| fefe1f3652 | |||
| 7634df3cff | |||
| 60938af9f0 | |||
| accf0ac142 | |||
| f90badfcd5 | |||
| 249606a358 | |||
| 5be5ff77d0 | |||
| 6519cbca79 | |||
| 50510a23da | |||
| 2279738371 | |||
| 3d59962112 | |||
| a96d8b6de9 | |||
| 94b9eeaa78 | |||
|
|
85fb091e22 | ||
| 2139f8729c | |||
| 98a48aa795 | |||
| d255d232b9 | |||
| 9999fcdd2a | |||
| 953ffdff97 | |||
| 5377a2b466 | |||
| e73148dc6c | |||
| a19772e3a7 | |||
| b3386eff43 | |||
| 79e74d9995 | |||
| 396a5610aa | |||
| b9329f6fb6 | |||
| 1b731a4ab0 | |||
| ab631365b2 | |||
| 9aa370d583 | |||
|
|
8cd75716ae | ||
|
|
4feccf2008 | ||
| 23e9a5a35b | |||
| 38a35be0f0 | |||
| f9f0572ec5 | |||
| 0221972e6d | |||
| 86ea72d56f | |||
| 114db7939c | |||
| 4503847c0d | |||
| 95c5fe76e6 | |||
| 34fa9c9c3e | |||
| 53793bfb68 | |||
| 9c7c8cb03a | |||
| 4fea176e28 | |||
| cf9492c7ba | |||
| e6def9e5f7 | |||
| 974185f354 | |||
| 6aacdd9111 | |||
| 4f263d00fe | |||
| bda002684c | |||
| b6bcd195b0 | |||
| 9fb4502ed0 | |||
| d398c72005 | |||
| 9d718e0f51 | |||
| 8ddc11159b | |||
| 2218c11d3f | |||
|
|
6736bae5ee | ||
| 60aa889c57 | |||
| 3c9767387b | |||
|
|
21d1726d7a | ||
| 1e7ee5b98a | |||
| a82df3f6d0 | |||
| e0e3eb73b9 | |||
| 8a7a0af207 | |||
| 71a652ce3b | |||
| 5fa6eac1ad | |||
| 21ba3fb7cf | |||
| 316c2b0b12 | |||
| ef77eab627 | |||
| 955d47ef12 | |||
| 8536774522 | |||
| a8aeae5f32 | |||
| 988ac009b5 | |||
| 494e486998 | |||
| 3c73028c80 | |||
| 2be1be19fe | |||
| 1f050c2be9 | |||
|
|
5771b603f2 | ||
|
|
5725989aff | ||
| 8b22e1b2af | |||
| a514b8b506 | |||
| 60fcc6b64c | |||
| d6bc660983 | |||
| b449fff77c | |||
| f0cc35f2ce | |||
| 2ce79942c4 | |||
| df426bb8df | |||
| 31f51554f9 | |||
| e967007bdc | |||
| 9f7427e72f | |||
| 79d9e0eaa0 | |||
| a98dd654a6 | |||
| a138e179dd | |||
| 94e3887345 | |||
| f440d385fa | |||
| 8fb4767888 | |||
| 993d74502f | |||
| 4a11c951ae | |||
| f25f308326 | |||
| bff683a403 | |||
| bb7954d026 | |||
| d9f4239729 | |||
| cf3b897305 | |||
| 87a78ca924 | |||
| 13debac340 | |||
| 76846420a2 | |||
| 65bd6c4254 | |||
| 1a3e4f501f | |||
| 974d65f619 | |||
|
|
721ca0d7e0 | ||
|
|
2556eba84b | ||
|
|
fc1ff97d22 | ||
|
|
b7f3d421ef | ||
|
|
2722b3427a | ||
|
|
e28c293d27 | ||
|
|
5cb2a836ab | ||
|
|
b5978e7a61 | ||
| c4a048adee | |||
|
|
2746ca5784 | ||
| fc9bd33992 | |||
| 2239c38ecc |
@@ -29,6 +29,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# Full history so the base..HEAD diff below has a merge-base.
|
# Full history so the base..HEAD diff below has a merge-base.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
# Cheap, always-on guard: the release build must stay reproducible for the
|
# Cheap, always-on guard: the release build must stay reproducible for the
|
||||||
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
@@ -82,6 +84,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ name: Translations
|
|||||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||||
|
#
|
||||||
|
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||||
|
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||||
|
# skipped on unrelated PRs and never posts its status, which leaves that
|
||||||
|
# required check pending forever and blocks the merge of any code-only PR into a
|
||||||
|
# release/* branch. The check itself is cheap and simply passes when the
|
||||||
|
# committed translations are consistent, so always running it costs nothing.
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
|
||||||
- 'app/src/main/res/values*/strings.xml'
|
|
||||||
- 'app/src/main/res/xml/locales_config.xml'
|
|
||||||
- 'scripts/check_translations.py'
|
|
||||||
- '.gitea/workflows/translations.yaml'
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: translations-${{ github.ref }}
|
group: translations-${{ github.ref }}
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,3 +55,6 @@ Thumbs.db
|
|||||||
|
|
||||||
# KSP
|
# KSP
|
||||||
.ksp/
|
.ksp/
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
/CLAUDE.md
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "floret-kit"]
|
||||||
|
path = floret-kit
|
||||||
|
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||||
269
CHANGELOG.md
269
CHANGELOG.md
@@ -5,6 +5,251 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Choose how the month view is laid out. A new **Month view style** setting
|
||||||
|
(Settings → Views) offers three ways to read a month, each shown with a
|
||||||
|
preview of the layout it produces:
|
||||||
|
- **Pages** — what you have today: one month at a time, swiped sideways.
|
||||||
|
- **Continuous** — scroll up and down through the weeks without a break
|
||||||
|
between months. Because the weeks run on unbroken, no month is cut off and
|
||||||
|
no day appears twice, where paging repeats a boundary week at the end of one
|
||||||
|
month and the start of the next. The 1st of each month names itself so you
|
||||||
|
always know where you are, and the title bar keeps up as you scroll ([#38]).
|
||||||
|
- **Split** — a compact grid showing coloured dots for the days that have
|
||||||
|
something on them, with the day you tap listed in full underneath. Tap the
|
||||||
|
date above the list to open the whole day ([#53]).
|
||||||
|
|
||||||
|
The Agenda view is untouched by this and stays available in all three styles —
|
||||||
|
the split layout lists a single day, while Agenda remains a rolling multi-day
|
||||||
|
window with its own range settings.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
||||||
|
it. Previously it was laid out once for the smallest size and simply stretched
|
||||||
|
when enlarged, so the text stayed small no matter how big you made the widget.
|
||||||
|
Now a bigger widget gets bigger, more readable type and roomier rows, while the
|
||||||
|
default size looks exactly as before — no new setting; it follows the size you
|
||||||
|
already chose ([#51]).
|
||||||
|
|
||||||
|
## [2.16.0] — 2026-07-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Give an event its own time zone. A new **Time zone** field (under "more
|
||||||
|
fields" in the event form) pins an event to a specific zone, so a call set for
|
||||||
|
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
|
||||||
|
tracking that zone across daylight-saving changes instead of drifting an hour.
|
||||||
|
The form edits the event in its own zone and shows the local equivalent under
|
||||||
|
the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local
|
||||||
|
time first and note the original beneath it, so both are always clear. Pick a
|
||||||
|
zone from a full-screen picker with your device zone and recent choices on top,
|
||||||
|
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
|
||||||
|
("CEST") to gather every matching zone at once. All-day events stay
|
||||||
|
date-anchored and carry no zone, as before ([#31]).
|
||||||
|
- Put the "jump to today" button in the toolbar. A new **Today button in
|
||||||
|
toolbar** setting (Settings → Appearance, off by default) swaps the floating
|
||||||
|
button that fades into the corner while you're away from today for a permanent
|
||||||
|
today icon in the top bar — always there, on today or not, matching the
|
||||||
|
familiar calendar-app pattern. Leave it off to keep the floating button as
|
||||||
|
before ([#60]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dates in the Month, Week and Day title bars now follow your language and
|
||||||
|
region instead of one hardcoded layout. Every date was rendered in a fixed
|
||||||
|
German-style order with a trailing dot on the day number, whatever your
|
||||||
|
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||||
|
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||||
|
disagreed about the same date. All four views now share one formatter, and the
|
||||||
|
day/month order, the separators and the ordinal all come from your locale —
|
||||||
|
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||||
|
each correct for where you are ([#60]).
|
||||||
|
- The title bar drops the year while you're in the current one — "July" rather
|
||||||
|
than "July 2026". The year reappears the moment you page out of the current
|
||||||
|
year, which is when it tells you something you didn't already know.
|
||||||
|
- The Week view's title now names the month instead of spelling out the day range.
|
||||||
|
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||||
|
headers right below it, in the widest string in the bar. A week that straddles
|
||||||
|
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- An all-day event no longer shows up again the day after it happened. In time
|
||||||
|
zones east of UTC, an all-day event — a birthday, say — set for one day also
|
||||||
|
appeared under the *next* day's heading in the Agenda (and the agenda widget),
|
||||||
|
because all-day events are anchored to UTC midnight and the following day's
|
||||||
|
window reached back across that boundary and pulled the event forward onto
|
||||||
|
"today". Each all-day event now lists only on the day it actually falls on
|
||||||
|
([#65]).
|
||||||
|
|
||||||
|
## [2.15.0] — 2026-07-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Show raw calendar colours. Calendula normally softens each calendar and event
|
||||||
|
colour toward a theme-fitting pastel so harsh sync colours read well on both
|
||||||
|
light and dark; a new **Soften calendar colours** setting (Settings → Design,
|
||||||
|
on by default) lets you turn that off and paint the exact colours your calendar
|
||||||
|
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
|
||||||
|
@leonp5 for the report ([#36]).
|
||||||
|
- Readable titles on dark event colours. An event bar's title now shows in white
|
||||||
|
on a dark colour and near-black on a light one, chosen automatically from the
|
||||||
|
colour's brightness, so a deep blue or purple event is legible at a glance in
|
||||||
|
the busy Week and Month views instead of dark-on-dark. This applies whether or
|
||||||
|
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
|
||||||
|
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
||||||
|
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
||||||
|
amount and switch between minutes and hours, so a snoozed reminder comes back
|
||||||
|
after exactly the delay you want instead of only a preset one ([#40]).
|
||||||
|
- Move an event to another calendar. When editing an existing event, the
|
||||||
|
calendar row is now tappable — pick a different calendar and saving moves the
|
||||||
|
event across, instead of having to delete it and recreate it elsewhere.
|
||||||
|
Recurring series move as a whole, keeping their individually-edited and
|
||||||
|
cancelled occurrences, and any reminders and guests come along too. A calendar
|
||||||
|
can't simply be reassigned underneath an event, so Calendula recreates it on
|
||||||
|
the target and removes the original — the same approach other calendar apps
|
||||||
|
take. Thanks to @prismplex for the suggestion ([#39]).
|
||||||
|
- Open an event straight into the edit form from another app. Calendula already
|
||||||
|
answered the "new event" and "open this event" hand-offs from other apps and
|
||||||
|
widgets; it now also answers the "edit this event" one, so an assistant, task
|
||||||
|
app, or widget can send an existing event to Calendula and land on its edit
|
||||||
|
screen rather than the read-only details. A hand-off with no event attached
|
||||||
|
opens the same prefilled create form as "new event". Calendula also recognises
|
||||||
|
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||||
|
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||||
|
into Calendula works too.
|
||||||
|
- Keep today at the top of the agenda. A new **Always show today** setting
|
||||||
|
(Settings → Agenda, on by default) anchors today as the first entry in both the
|
||||||
|
Agenda screen and its home-screen widget even once nothing is left today —
|
||||||
|
under today's header a "No more events today" note appears — so the first
|
||||||
|
events you see are clearly today's rather than a future day's. Turn it off to
|
||||||
|
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
|
||||||
|
- Duplicate an event. The event details now carry a **Duplicate** action that
|
||||||
|
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
|
||||||
|
a one-off like a shift or an appointment can be recreated by just changing the
|
||||||
|
day and time instead of re-typing every field. The copy keeps the original's
|
||||||
|
time, and its title, location, notes, colour, guests and reminders come along;
|
||||||
|
it's saved as its own single event (any repeat is left off — add one in the
|
||||||
|
editor if you want it). Duplicate works from read-only calendars too, dropping
|
||||||
|
the copy into a writable one. Thanks to @internet-rando for the suggestion
|
||||||
|
([#52]).
|
||||||
|
- French and Polish, in early form. Calendula has started speaking French and
|
||||||
|
Polish, both contributed as community translations through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
They are partway there, so untranslated parts still show in English until they
|
||||||
|
fill out — you can already pick either under Settings → Language or in Android's
|
||||||
|
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
|
||||||
|
(Polish) for getting them started; help finishing them is very welcome.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Reminders for events on another day no longer read as if they were today. A
|
||||||
|
reminder fired ahead of time — say, the day before — used to show only the
|
||||||
|
event's time, making it look like it was happening now. The notification now
|
||||||
|
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
||||||
|
week, or the date for anything further out. Thanks to @moonj for the report
|
||||||
|
([#46]).
|
||||||
|
- Agenda dates now read in your locale's format. The agenda's range bar and its
|
||||||
|
day headers used a fixed day-month-year layout — and the range span even mixed
|
||||||
|
two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's
|
||||||
|
conventions. Dates across the agenda and its widget now use your locale's own
|
||||||
|
field order, matching the rest of the app. The range bar also no longer repeats
|
||||||
|
the range's name from the selector button beside it, showing just the dates.
|
||||||
|
- Calendar gutters line up with the menu button. The Month view's week-number
|
||||||
|
column, and the Week and Day views' hour labels, sat a few pixels left of the
|
||||||
|
hamburger menu above them; they now line up with it. In Month view the day
|
||||||
|
cells also sit squarely under their weekday letters.
|
||||||
|
|
||||||
|
## [2.14.1] — 2026-07-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Deleting one occurrence of a repeating event no longer breaks the series.
|
||||||
|
Choosing "This event" when deleting an occurrence of a recurring event could
|
||||||
|
wipe out every *other* occurrence while leaving the one you deleted behind as
|
||||||
|
a stale, still-tappable ghost — and deleting it again brought the series back.
|
||||||
|
A single-occurrence delete now removes exactly that occurrence and leaves the
|
||||||
|
rest of the series untouched, and the deleted occurrence disappears from the
|
||||||
|
grid straight away. This holds on every kind of calendar, including the
|
||||||
|
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
||||||
|
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
||||||
|
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
||||||
|
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
||||||
|
it also answers the "open this event" one, so tapping an existing event in a
|
||||||
|
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
||||||
|
Thanks to @bushrang3r for the report ([#48]).
|
||||||
|
- Events created from other apps get your default reminder. An event handed over
|
||||||
|
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
||||||
|
widget's "+" — opened with no reminder at all, ignoring the default set in
|
||||||
|
Settings. It now starts with your default reminder, the same as an event you
|
||||||
|
create in Calendula. An event opened from an `.ics` file is treated differently,
|
||||||
|
because the file has its own say: Calendula keeps whatever reminders it carries
|
||||||
|
(including none at all) and asks you once whether to apply your default instead
|
||||||
|
— it never quietly overrides the file. If you have no default set, it doesn't
|
||||||
|
ask ([#49]).
|
||||||
|
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||||
|
app (such as DAVx5), the event colour picker showed every colour the account
|
||||||
|
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||||
|
duplicates or near-identical shades. The picker now shows only visually
|
||||||
|
distinct colours, arranged as a rainbow; near-duplicate shades and the
|
||||||
|
washed-out neutrals are folded away so no two swatches look alike. Picked
|
||||||
|
colours still sync exactly as before, and calendars with hand-picked
|
||||||
|
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
||||||
|
([#22]).
|
||||||
|
|
||||||
|
## [2.14.0] — 2026-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Restore events from a backup file. The backup section of Settings can now read
|
||||||
|
events back **in** from an `.ics` file, not just write one out: pick a file,
|
||||||
|
choose which calendar to import into, and Calendula adds the events — skipping
|
||||||
|
any that are already there and telling you how many it skipped. Export gained a
|
||||||
|
per-calendar selector at the same time, so you can back up just the calendars
|
||||||
|
you pick instead of everything at once ([#32]).
|
||||||
|
- Week numbers in Month view. A new **Week numbers** setting (off by default)
|
||||||
|
adds a slim gutter down the left of the Month grid showing the calendar-week
|
||||||
|
number for each row, sized to match the day cells. Handy if you plan or refer
|
||||||
|
to dates by week number ([#25]).
|
||||||
|
- Tap a date header to open that day. In Week and Agenda view, tapping a date
|
||||||
|
header now opens that date in Day view — the same drill-in that Month view and
|
||||||
|
the agenda widget already offered, so every view behaves the same way. It makes
|
||||||
|
jumping to a specific day quicker: switch to Week, swipe to the week you want,
|
||||||
|
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
|
||||||
|
- An early Simplified Chinese translation. Calendula has started speaking
|
||||||
|
Simplified Chinese, contributed as a community translation through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
It is still an early effort, so many parts of the app show in English until it
|
||||||
|
fills out — you can already pick it under Settings → Language or in Android's
|
||||||
|
per-app language settings. Thanks to
|
||||||
|
[zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it
|
||||||
|
started; help finishing it is very welcome.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Long event titles wrap in the edit screen. When editing an event, a long title
|
||||||
|
now wraps onto multiple lines instead of being clipped to a single line, so you
|
||||||
|
can see and edit the whole thing ([#33]).
|
||||||
|
|
||||||
|
## [2.13.1] — 2026-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Create events from other apps and widgets. Calendula now registers the
|
||||||
|
standard "insert event" intent (`ACTION_INSERT` on the calendar events type),
|
||||||
|
so other apps and home-screen widgets — such as the Todo Agenda widget — can
|
||||||
|
hand off to Calendula to create a new event. It opens the new-event form
|
||||||
|
prefilled with whatever they passed (title, start/end time, all-day, location,
|
||||||
|
description, recurrence), and picks your last-used or first writable calendar.
|
||||||
|
Thanks to @dschuermann for the suggestion ([#30]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Some recurring events could not be opened. Events in a series that started
|
||||||
|
before 1970 — for example yearly birthdays or anniversaries synced over CalDAV
|
||||||
|
— showed "Something went wrong" instead of opening, because their stored start
|
||||||
|
time is a negative value that was wrongly treated as invalid. They now open
|
||||||
|
normally and appear in search again. A related case (an event whose stored end
|
||||||
|
precedes its start) is now kept and openable instead of failing the same way.
|
||||||
|
Thanks to @dschuermann for the report ([#34]).
|
||||||
|
- The time picker now follows your 24-hour setting. With Calendula set to
|
||||||
|
24-hour time, the clock dial for choosing an event's start and end time still
|
||||||
|
showed AM/PM instead of a 24-hour dial; it now matches your setting (and the
|
||||||
|
same fix applies to the all-day reminder time in Settings). Thanks to
|
||||||
|
@abrossimow for the report ([#27]).
|
||||||
|
|
||||||
## [2.13.0] — 2026-07-03
|
## [2.13.0] — 2026-07-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -808,5 +1053,29 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||||
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||||
|
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
|
||||||
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||||
|
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
|
||||||
|
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
||||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||||
|
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
||||||
|
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||||
|
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
|
||||||
|
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||||
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
|
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
|
||||||
|
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||||
|
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||||
|
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
|
||||||
|
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
||||||
|
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
||||||
|
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||||
|
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||||
|
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||||
|
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||||
|
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||||
|
[#38]: https://codeberg.org/jlmakiola/calendula/issues/38
|
||||||
|
[#53]: https://codeberg.org/jlmakiola/calendula/issues/53
|
||||||
|
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||||
|
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21300
|
versionCode = 21600
|
||||||
versionName = "2.13.0"
|
versionName = "2.16.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -161,6 +161,12 @@ dependencies {
|
|||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-time")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||||
|
implementation("de.jeanlucmakiola.floret:identity")
|
||||||
|
implementation("de.jeanlucmakiola.floret:components")
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
|||||||
import androidx.test.rule.GrantPermissionRule
|
import androidx.test.rule.GrantPermissionRule
|
||||||
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.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest {
|
|||||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||||
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
||||||
)
|
)
|
||||||
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
|
return CalendarRepositoryImpl(
|
||||||
|
dataSource,
|
||||||
|
CalendarPrefs(store),
|
||||||
|
SettingsPrefs(store),
|
||||||
|
Dispatchers.IO,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -63,10 +63,9 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
|
||||||
<action android:name="android.intent.action.MAIN" />
|
the two <activity-alias> below, so the app name can be switched at
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
runtime (issue #44). MainActivity keeps every other filter. -->
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Be selectable as the system calendar app. Android has no API for
|
<!-- Be selectable as the system calendar app. Android has no API for
|
||||||
an app to make itself the default, so registering the filters
|
an app to make itself the default, so registering the filters
|
||||||
@@ -97,26 +96,115 @@
|
|||||||
<data android:mimeType="time/epoch" />
|
<data android:mimeType="time/epoch" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
|
||||||
|
The three MIME types cover the common labels the same calendar
|
||||||
|
data arrives under: iCalendar 2.0 (text/calendar), the older
|
||||||
|
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
|
||||||
|
mail apps emit — Android cross-products the scheme and mimeType
|
||||||
|
tags, so each MIME is accepted on both schemes (matches Etar). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="content" android:mimeType="text/calendar" />
|
<data android:scheme="content" />
|
||||||
<data android:scheme="file" android:mimeType="text/calendar" />
|
<data android:scheme="file" />
|
||||||
|
<data android:mimeType="text/calendar" />
|
||||||
|
<data android:mimeType="text/x-vcalendar" />
|
||||||
|
<data android:mimeType="application/ics" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Receive a .ics shared from another app. -->
|
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
|
<data android:mimeType="text/x-vcalendar" />
|
||||||
|
<data android:mimeType="application/ics" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
||||||
|
to create a new event, the way the AOSP calendar accepts it:
|
||||||
|
ACTION_INSERT on the events dir mime type, carrying the new
|
||||||
|
event's fields as CalendarContract extras
|
||||||
|
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
||||||
|
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
||||||
|
to the same prefilled create form. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.INSERT" />
|
||||||
|
<action android:name="android.intent.action.EDIT" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Edit an existing event another app/assistant/widget points at:
|
||||||
|
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
|
||||||
|
AOSP fires it. Opens the occurrence in the edit form (not the
|
||||||
|
read-only detail — that's the VIEW filter above). Matched by the
|
||||||
|
provider's item MIME type, like the VIEW filter. The occurrence's
|
||||||
|
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
|
||||||
|
supplied (MainActivity.editEventKeyOrNull). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.EDIT" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Open an existing event another app/widget points at (e.g. tapping
|
||||||
|
an event in the Todo Agenda widget): ACTION_VIEW on
|
||||||
|
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
||||||
|
Matched by the provider's item MIME type, not the path — a
|
||||||
|
content: VIEW intent carries the resolved type
|
||||||
|
(vnd.android.cursor.item/event) and a path-only filter wouldn't
|
||||||
|
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
|
||||||
|
EXTRA_EVENT_END_TIME when the launcher supplies them
|
||||||
|
(MainActivity.viewEventKeyOrNull, issue #48). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Launcher entry for MainActivity, split into two aliases so the app's
|
||||||
|
launcher name can be switched at runtime between "Calendula" and
|
||||||
|
"Calendar" (issue #44). Exactly one is enabled at a time; the app
|
||||||
|
flips them via PackageManager.setComponentEnabledSetting
|
||||||
|
(LauncherNameManager). The shortcuts meta-data lives here, not on
|
||||||
|
MainActivity, because static shortcuts are published by whichever
|
||||||
|
component owns the MAIN/LAUNCHER filter. -->
|
||||||
|
<activity-alias
|
||||||
|
android:name=".DefaultNameAlias"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
android:resource="@xml/shortcuts" />
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".CalendarNameAlias"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name_calendar_alias"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
<!-- Standalone surface for a captured crash report. MainActivity routes
|
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||||
here on a startup crash-loop, so it stays clear of the app's Hilt
|
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
|||||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -24,8 +25,18 @@ class CalendulaApp : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// Install first thing so startup crashes are captured too (privacy-
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
// respecting, on-device; the user submits the report by hand).
|
// respecting, on-device; the user submits the report by hand). The
|
||||||
CrashReporter.install(this)
|
// capture/loop-detection/report machinery lives in floret-kit's
|
||||||
|
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
||||||
|
CrashReporter.install(
|
||||||
|
this,
|
||||||
|
CrashConfig(
|
||||||
|
appLabel = getString(R.string.app_name),
|
||||||
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
|
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||||
|
issueTitle = getString(R.string.crash_report_issue_title),
|
||||||
|
),
|
||||||
|
)
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.CalendarContract
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -22,26 +23,31 @@ 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 dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||||
|
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -60,6 +66,16 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// by CalendarHost's import flow.
|
// by CalendarHost's import flow.
|
||||||
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
private var requestedImportUri by mutableStateOf<Uri?>(null)
|
||||||
|
|
||||||
|
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||||
|
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||||
|
// CalendarHost, which opens it in the create form for review.
|
||||||
|
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||||
|
|
||||||
|
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||||
|
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||||
|
// detail channel; consumed once by CalendarHost.
|
||||||
|
private var requestedEditKey by mutableStateOf<LongArray?>(null)
|
||||||
|
|
||||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
// over the calendar on the next launch (the single-crash path). A startup
|
// over the calendar on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
@@ -81,9 +97,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
|
requestedInsertForm = intent.insertFormOrNull()
|
||||||
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -123,6 +141,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -132,6 +151,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = { requestedInsertForm = null },
|
||||||
|
requestedEditKey = requestedEditKey,
|
||||||
|
onEditKeyConsumed = { requestedEditKey = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A persistent corner marker so a debug build is never
|
// A persistent corner marker so a debug build is never
|
||||||
@@ -166,9 +189,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
|
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||||
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,6 +213,37 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prefilled new-event form from an external launch asking us to create an
|
||||||
|
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
||||||
|
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||||
|
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||||
|
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||||
|
* [buildInsertEventForm].
|
||||||
|
*/
|
||||||
|
private fun Intent.insertFormOrNull(): EventForm? {
|
||||||
|
// ACTION_EDIT on an existing event routes to the edit form instead
|
||||||
|
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
|
||||||
|
val isCreate = action == Intent.ACTION_INSERT ||
|
||||||
|
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
|
||||||
|
if (!isCreate) return null
|
||||||
|
return buildInsertEventForm(
|
||||||
|
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||||
|
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||||
|
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||||
|
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||||
|
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||||
|
location = getStringExtra(CalendarContract.Events.EVENT_LOCATION),
|
||||||
|
rrule = getStringExtra(CalendarContract.Events.RRULE),
|
||||||
|
zone = TimeZone.currentSystemDefault(),
|
||||||
|
now = Clock.System.now(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Long extra's value, or null when the extra is absent. */
|
||||||
|
private fun Intent.longExtraOrNull(key: String): Long? =
|
||||||
|
if (hasExtra(key)) getLongExtra(key, 0L) else null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||||
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||||
@@ -255,6 +311,55 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The detail key for an external "open this event" — ACTION_VIEW on
|
||||||
|
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||||
|
* tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
|
||||||
|
* same occurrence-key channel as reminder taps. The launcher passes the
|
||||||
|
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
|
||||||
|
* it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
|
||||||
|
* [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
|
||||||
|
* rather than rendering at the epoch.
|
||||||
|
*/
|
||||||
|
private fun Intent.viewEventKeyOrNull(): LongArray? {
|
||||||
|
if (action != Intent.ACTION_VIEW) return null
|
||||||
|
val uri = data ?: return null
|
||||||
|
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||||
|
val segments = uri.pathSegments
|
||||||
|
if (segments.firstOrNull() != "events") return null
|
||||||
|
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||||
|
return longArrayOf(
|
||||||
|
eventId,
|
||||||
|
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||||
|
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
|
||||||
|
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||||
|
* an assistant, task app, or widget that wants to open the event for editing
|
||||||
|
* rather than viewing). Opens it in the edit form. Reuses the same
|
||||||
|
* occurrence-key channel as reminder/view taps; the caller passes the
|
||||||
|
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
||||||
|
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
||||||
|
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
||||||
|
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
|
||||||
|
*/
|
||||||
|
private fun Intent.editEventKeyOrNull(): LongArray? {
|
||||||
|
if (action != Intent.ACTION_EDIT) return null
|
||||||
|
val uri = data ?: return null
|
||||||
|
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||||
|
val segments = uri.pathSegments
|
||||||
|
if (segments.firstOrNull() != "events") return null
|
||||||
|
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||||
|
return longArrayOf(
|
||||||
|
eventId,
|
||||||
|
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||||
|
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// The calendar provider's authority/host. A date tap arrives as
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.appname
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** The launcher label the app shows for itself (issue #44). */
|
||||||
|
enum class LauncherName { CALENDULA, CALENDAR }
|
||||||
|
|
||||||
|
/** The two launcher aliases declared in the manifest. */
|
||||||
|
enum class LauncherAlias { DEFAULT, CALENDAR }
|
||||||
|
|
||||||
|
/** One component-enable change in a [aliasWritePlan]. */
|
||||||
|
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret the `CalendarNameAlias` component-enabled state as a [LauncherName].
|
||||||
|
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the "Calendar"
|
||||||
|
* name is active; `DEFAULT` (never toggled) and `DISABLED` both resolve to the
|
||||||
|
* manifest default, "Calendula".
|
||||||
|
*/
|
||||||
|
fun launcherNameFor(calendarAliasState: Int): LauncherName =
|
||||||
|
if (calendarAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||||
|
LauncherName.CALENDAR
|
||||||
|
} else {
|
||||||
|
LauncherName.CALENDULA
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ordered enable/disable steps to switch the launcher name to [target].
|
||||||
|
* Always **enables the target alias first, then disables the other** — the two
|
||||||
|
* `setComponentEnabledSetting` calls are not atomic, and this ordering means the
|
||||||
|
* only transient the launcher can observe is a harmless two-entry state, never a
|
||||||
|
* zero-entry one (which would briefly drop the app from the launcher).
|
||||||
|
*/
|
||||||
|
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
|
||||||
|
LauncherName.CALENDAR -> listOf(
|
||||||
|
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||||
|
)
|
||||||
|
LauncherName.CALENDULA -> listOf(
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the app's launcher label between "Calendula" and "Calendar" by
|
||||||
|
* enabling/disabling the two `<activity-alias>` components (issue #44). The
|
||||||
|
* component-enabled state is the single source of truth — there is no persisted
|
||||||
|
* preference — so [current] reads it straight from [PackageManager] and the two
|
||||||
|
* always agree.
|
||||||
|
*
|
||||||
|
* The decision logic lives in the pure [launcherNameFor] / [aliasWritePlan]
|
||||||
|
* functions above (JVM-tested); this class is only the thin framework seam.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LauncherNameManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val packageManager: PackageManager get() = context.packageManager
|
||||||
|
|
||||||
|
// The class portion is namespace-qualified (suffix-free), the package portion
|
||||||
|
// is the applicationId (which carries the .debug / .releasetest suffix). The
|
||||||
|
// manifest's ".CalendarNameAlias" resolves its class against the namespace, so
|
||||||
|
// the real component is <namespace>.CalendarNameAlias registered under the
|
||||||
|
// suffixed applicationId. Do NOT use ComponentName(context, ".CalendarNameAlias")
|
||||||
|
// — its leading-dot form prepends the applicationId to the class too, producing
|
||||||
|
// "<appId>.CalendarNameAlias" and failing on every debug / releaseTest build.
|
||||||
|
private fun component(alias: LauncherAlias): ComponentName {
|
||||||
|
val simpleName = when (alias) {
|
||||||
|
LauncherAlias.DEFAULT -> "DefaultNameAlias"
|
||||||
|
LauncherAlias.CALENDAR -> "CalendarNameAlias"
|
||||||
|
}
|
||||||
|
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The launcher name currently in effect. */
|
||||||
|
fun current(): LauncherName =
|
||||||
|
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.CALENDAR)))
|
||||||
|
|
||||||
|
/** Switch the launcher name to [name] (no-op cost if already active). */
|
||||||
|
fun set(name: LauncherName) {
|
||||||
|
for (change in aliasWritePlan(name)) {
|
||||||
|
val state = if (change.enabled) {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
} else {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
}
|
||||||
|
packageManager.setComponentEnabledSetting(
|
||||||
|
component(change.alias),
|
||||||
|
state,
|
||||||
|
PackageManager.DONT_KILL_APP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* The app's namespace (R-class package) — NOT `applicationId`, which
|
||||||
|
* carries the build-type suffix. Kept in sync with `namespace` in
|
||||||
|
* `app/build.gradle.kts`.
|
||||||
|
*/
|
||||||
|
const val NAMESPACE = "de.jeanlucmakiola.calendula"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
@@ -16,10 +18,13 @@ import android.util.Log
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
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.AttendeeRelationship
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
@@ -61,19 +66,23 @@ interface CalendarDataSource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette the calendar's account publishes
|
* The event-colour palette the calendar's account publishes
|
||||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
|
||||||
* account exposes no palette (most local calendars, some CalDAV) — the
|
* thinned to visually distinct swatches when oversized (CalDAV adapters
|
||||||
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
|
||||||
* which a synced calendar may drop on its next sync.
|
* 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>
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every master/one-off event of the writable local calendars, mapped for a
|
* Every master/one-off event of the writable local calendars, mapped for a
|
||||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||||
* rows are excluded (see [EventExportProjection]).
|
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||||
|
* given, only those calendars are exported (still intersected with the
|
||||||
|
* eligible set); `null` exports every eligible calendar.
|
||||||
*/
|
*/
|
||||||
fun exportableEvents(): List<IcsEvent>
|
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||||
@@ -172,6 +181,25 @@ interface CalendarDataSource {
|
|||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an event (for recurring events: the whole series, with its modified
|
||||||
|
* and cancelled occurrences) to [targetCalendarId], returning the new
|
||||||
|
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
|
||||||
|
* place, so this is copy+delete: the row is re-inserted on the target (its
|
||||||
|
* `UID_2445` preserved), its exceptions, reminders and editable guests
|
||||||
|
* replayed, the [original]→[updated] field edits applied, then the source
|
||||||
|
* deleted. The source is removed only once the copy fully succeeds; a failure
|
||||||
|
* before that rolls the new event back, leaving the original untouched.
|
||||||
|
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
|
*/
|
||||||
|
fun moveEvent(
|
||||||
|
eventId: Long,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event by inserting a
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
@@ -488,7 +516,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return resolver.query(
|
return resolver.query(
|
||||||
uri,
|
uri,
|
||||||
InstanceProjection.COLUMNS,
|
InstanceProjection.COLUMNS,
|
||||||
null, null,
|
// Hide cancelled occurrences: "delete only this event" writes a
|
||||||
|
// cancelled exception for the one instance (#47). A NULL status is a
|
||||||
|
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||||
|
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||||
|
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
||||||
|
null,
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
@@ -607,16 +641,23 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||||
}
|
}
|
||||||
?.filter { it.key.isNotEmpty() }
|
?.filter { it.key.isNotEmpty() }
|
||||||
?.sortedBy { it.key }
|
?.curatedForPicker()
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun exportableEvents(): List<IcsEvent> {
|
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||||
// Only the local calendars the app owns and can write — synced calendars
|
// Only the local calendars the app owns and can write — synced calendars
|
||||||
// already have a backup (their server). Map id → display name for the
|
// already have a backup (their server). Exclude the managed special-dates
|
||||||
|
// mirror calendars: their events are derived from contacts, not authored
|
||||||
|
// here, and re-materialise from the contact sync — backing them up would
|
||||||
|
// just duplicate them on restore. A non-null [calendarIds] narrows the
|
||||||
|
// export to the user's chosen subset. Map id → display name for the
|
||||||
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||||
val names = calendars()
|
val names = calendars()
|
||||||
.filter { it.isLocal && it.canModifyContents }
|
.filter {
|
||||||
|
it.isLocal && it.canModifyContents && !it.isManaged &&
|
||||||
|
(calendarIds == null || it.id in calendarIds)
|
||||||
|
}
|
||||||
.associate { it.id to it.displayName }
|
.associate { it.id to it.displayName }
|
||||||
if (names.isEmpty()) return emptyList()
|
if (names.isEmpty()) return emptyList()
|
||||||
|
|
||||||
@@ -865,6 +906,190 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun moveEvent(
|
||||||
|
eventId: Long,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
|
// CALENDAR_ID can't be updated in place, so re-create the event on the
|
||||||
|
// target calendar and delete the source. Everything that identifies or
|
||||||
|
// hangs off the event is copied first; the source row is removed only
|
||||||
|
// once the copy (including its exceptions) is complete — a failure
|
||||||
|
// anywhere before that rolls the new event back, so the move is
|
||||||
|
// all-or-nothing and never leaves a half-copied duplicate.
|
||||||
|
val master = queryMoveMaster(eventId)
|
||||||
|
?: throw WriteFailedException("read event to move id=$eventId")
|
||||||
|
// Keep the source UID so an .ics backup dedups and a sync adapter can
|
||||||
|
// recognise the moved event; mint one only if the source carried none.
|
||||||
|
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
|
||||||
|
val newEventId = resolver.insert(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
|
||||||
|
)?.let(ContentUris::parseId)
|
||||||
|
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
|
||||||
|
try {
|
||||||
|
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
|
||||||
|
insertAttendees(newEventId, editableAttendees(eventId))
|
||||||
|
copyExceptions(fromEventId = eventId, toEventId = newEventId)
|
||||||
|
// Apply the user's field edits exactly as a same-calendar "all
|
||||||
|
// events" save would: the moved master carries the source's values,
|
||||||
|
// so the dirty diff against [original] writes only what changed
|
||||||
|
// (including a time or rrule edit made in the same save).
|
||||||
|
updateEvent(newEventId, original, updated, allDayReminderTimeMinutes)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// Undo the partial copy so a failed move leaves nothing behind; the
|
||||||
|
// source is still intact (it's deleted only past this point).
|
||||||
|
runCatching { deleteEvent(newEventId) }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
deleteEvent(eventId)
|
||||||
|
return newEventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The master row of the event to move, as a verbatim-insert snapshot. */
|
||||||
|
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
MoveMasterProjection.COLUMNS,
|
||||||
|
null, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
if (!c.moveToFirst()) return@use null
|
||||||
|
val r = CursorColumnReader(c)
|
||||||
|
MoveMaster(
|
||||||
|
uid = r.getString(MoveMasterProjection.IDX_UID),
|
||||||
|
event = MasterEventSnapshot(
|
||||||
|
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
|
||||||
|
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
|
||||||
|
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
|
||||||
|
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
|
||||||
|
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
|
||||||
|
duration = r.getString(MoveMasterProjection.IDX_DURATION),
|
||||||
|
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
|
||||||
|
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
|
||||||
|
exdate = r.getString(MoveMasterProjection.IDX_EXDATE),
|
||||||
|
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
|
||||||
|
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
|
||||||
|
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
|
||||||
|
status = r.getInt(MoveMasterProjection.IDX_STATUS)
|
||||||
|
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
|
||||||
|
location = r.getString(MoveMasterProjection.IDX_LOCATION),
|
||||||
|
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot)
|
||||||
|
|
||||||
|
/** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */
|
||||||
|
private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD),
|
||||||
|
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||||
|
arrayOf(fromEventId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, toEventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
|
||||||
|
put(CalendarContract.Reminders.METHOD, c.getInt(1))
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
|
||||||
|
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's editable guests as [EventAttendee]s — the rows the move
|
||||||
|
* re-creates. Mirrors the edit form's filter: the organizer and resource
|
||||||
|
* rows (backend-owned, not user-editable) and any without an email are
|
||||||
|
* dropped; the response status resets to "invited" on re-insert.
|
||||||
|
*/
|
||||||
|
private fun editableAttendees(eventId: Long): List<EventAttendee> = queryAttendees(eventId)
|
||||||
|
.filter {
|
||||||
|
it.relationship != AttendeeRelationship.Organizer && it.type != AttendeeType.Resource
|
||||||
|
}
|
||||||
|
.mapNotNull { a ->
|
||||||
|
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||||
|
EventAttendee(email = email, name = a.name, optional = a.type == AttendeeType.Optional)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay every exception of the series [fromEventId] against the moved series
|
||||||
|
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
|
||||||
|
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
|
||||||
|
* exception, a modified one is re-inserted with its fields (then its reminders
|
||||||
|
* reconciled and editable guests copied). No-op for a non-recurring event.
|
||||||
|
*/
|
||||||
|
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
|
||||||
|
queryExceptionRows(fromEventId).forEach { ex ->
|
||||||
|
if (ex.isCancelled) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
|
||||||
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
|
}
|
||||||
|
resolver.insert(
|
||||||
|
ContentUris.withAppendedId(
|
||||||
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
||||||
|
),
|
||||||
|
values,
|
||||||
|
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
|
||||||
|
} else {
|
||||||
|
val uri = resolver.insert(
|
||||||
|
ContentUris.withAppendedId(
|
||||||
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
||||||
|
),
|
||||||
|
buildCopiedExceptionValues(ex).toContentValues(),
|
||||||
|
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
|
||||||
|
val newExceptionId = ContentUris.parseId(uri)
|
||||||
|
// The provider may clone the parent's reminders onto the new
|
||||||
|
// exception; reconcile to the source exception's exact set so
|
||||||
|
// they neither double nor drop. Same DTSTART → same all-day
|
||||||
|
// encoding, so the stored offsets match directly.
|
||||||
|
reconcileReminders(
|
||||||
|
newExceptionId,
|
||||||
|
queryReminders(ex.exceptionEventId).map { it.minutes },
|
||||||
|
)
|
||||||
|
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
||||||
|
private fun queryExceptionRows(seriesEventId: Long): List<ExceptionRowSnapshot> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
ExceptionProjection.COLUMNS,
|
||||||
|
"${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
|
||||||
|
arrayOf(seriesEventId.toString()),
|
||||||
|
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
c.mapAll {
|
||||||
|
val r = CursorColumnReader(c)
|
||||||
|
val status = r.getInt(ExceptionProjection.IDX_STATUS)
|
||||||
|
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
|
||||||
|
ExceptionRowSnapshot(
|
||||||
|
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
|
||||||
|
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
||||||
|
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
|
||||||
|
status = status,
|
||||||
|
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
|
||||||
|
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
|
||||||
|
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
|
||||||
|
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
|
||||||
|
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
|
||||||
|
duration = r.getString(ExceptionProjection.IDX_DURATION),
|
||||||
|
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
|
||||||
|
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
|
||||||
|
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
|
||||||
|
location = r.getString(ExceptionProjection.IDX_LOCATION),
|
||||||
|
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -959,6 +1184,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events._SYNC_ID,
|
||||||
|
CalendarContract.Events.EXDATE,
|
||||||
),
|
),
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -969,6 +1196,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
timezone = c.getString(2),
|
timezone = c.getString(2),
|
||||||
duration = c.getString(3),
|
duration = c.getString(3),
|
||||||
allDay = c.getInt(4),
|
allDay = c.getInt(4),
|
||||||
|
syncId = c.getString(5),
|
||||||
|
exdate = c.getString(6),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -981,6 +1210,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val timezone: String?,
|
val timezone: String?,
|
||||||
val duration: String?,
|
val duration: String?,
|
||||||
val allDay: Int,
|
val allDay: Int,
|
||||||
|
/** Null on a local calendar (and before a synced event's first push). */
|
||||||
|
val syncId: String? = null,
|
||||||
|
val exdate: String? = null,
|
||||||
) {
|
) {
|
||||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
@@ -1171,16 +1403,49 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
// A cancelled exception row hides exactly this occurrence; the sync
|
val row = querySeriesRow(eventId)
|
||||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
|
if (row.syncId == null) {
|
||||||
val values = ContentValues().apply {
|
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
||||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
// A cancelled exception can only attach to its parent through
|
||||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
// ORIGINAL_SYNC_ID, so with none the link never forms and the
|
||||||
|
// provider's expansion of the *parent* collapses, taking every other
|
||||||
|
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
|
||||||
|
// Calendula's own contact special-date calendars are local and hold
|
||||||
|
// yearly series, so this path is reached in normal use.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = row.exdate,
|
||||||
|
occurrenceMillis = beginMillis,
|
||||||
|
dtStartMillis = row.dtStartMillis,
|
||||||
|
rrule = row.rrule,
|
||||||
|
duration = row.duration,
|
||||||
|
timezone = row.timezone,
|
||||||
|
allDay = row.allDay,
|
||||||
|
)
|
||||||
|
val updated = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(), null, null,
|
||||||
|
)
|
||||||
|
if (updated == 0) {
|
||||||
|
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A cancelled exception row hides exactly this occurrence; the sync
|
||||||
|
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It carries
|
||||||
|
// the full time set (DTSTART + DURATION + zone) so the provider derives a
|
||||||
|
// single instance rather than cloning the master's RRULE — the same trap
|
||||||
|
// the edit path documents (Codeberg #16).
|
||||||
|
val values = buildOccurrenceCancelValues(
|
||||||
|
originalInstanceMillis = beginMillis,
|
||||||
|
dtStartMillis = beginMillis,
|
||||||
|
duration = row.duration,
|
||||||
|
timezone = row.timezone,
|
||||||
|
allDay = row.allDay,
|
||||||
|
)
|
||||||
val uri = ContentUris.withAppendedId(
|
val uri = ContentUris.withAppendedId(
|
||||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||||
)
|
)
|
||||||
resolver.insert(uri, values)
|
resolver.insert(uri, values.toContentValues())
|
||||||
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ interface CalendarRepository {
|
|||||||
/**
|
/**
|
||||||
* Every event of the writable local calendars, ready to serialise into a
|
* Every event of the writable local calendars, ready to serialise into a
|
||||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||||
|
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
|
||||||
*/
|
*/
|
||||||
suspend fun exportEvents(): List<IcsEvent>
|
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||||
@@ -60,6 +61,20 @@ interface CalendarRepository {
|
|||||||
*/
|
*/
|
||||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an event (recurring: the whole series, with its exceptions) to
|
||||||
|
* [targetCalendarId] and apply the [original]→[updated] field edits; returns
|
||||||
|
* the new event's `Events._ID`. Copy+delete under the hood
|
||||||
|
* (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in
|
||||||
|
* place.
|
||||||
|
*/
|
||||||
|
suspend fun moveEvent(
|
||||||
|
eventId: Long,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event (exception row with the
|
* Change a single occurrence of a recurring event (exception row with the
|
||||||
* form's values); returns the exception's `Events._ID`.
|
* form's values); returns the exception's `Events._ID`.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
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.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
@@ -117,7 +118,8 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun deleteCalendar(id: Long) =
|
override suspend fun deleteCalendar(id: Long) =
|
||||||
withContext(io) { dataSource.deleteCalendar(id) }
|
withContext(io) { dataSource.deleteCalendar(id) }
|
||||||
|
|
||||||
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||||
|
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||||
|
|
||||||
override suspend fun importEvents(
|
override suspend fun importEvents(
|
||||||
targetCalendarId: Long,
|
targetCalendarId: Long,
|
||||||
@@ -155,6 +157,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.deleteEvent(eventId)
|
dataSource.deleteEvent(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun moveEvent(
|
||||||
|
eventId: Long,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long = withContext(io) {
|
||||||
|
dataSource.moveEvent(
|
||||||
|
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun updateOccurrence(
|
override suspend fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
@@ -13,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -23,26 +25,32 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
|
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||||
|
// Dropping negatives made every occurrence of such a series un-openable
|
||||||
|
// (the detail loads the ancient series-master DTSTART), see issue #34.
|
||||||
|
if (isNull(EventDetailProjection.IDX_DTSTART)) {
|
||||||
|
Log.w(TAG, "Dropping event with missing dtstart")
|
||||||
|
return null
|
||||||
|
}
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
if (begin < 0L) {
|
|
||||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
// DTEND is null — derive the length from DURATION (as SearchMapper and
|
||||||
// specific occurrence supply the real per-occurrence times from
|
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
|
||||||
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
// both times with the per-occurrence values from CalendarContract.Instances;
|
||||||
|
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
|
||||||
|
// intent, issue #48) keeps this row's own times, so the length has to be
|
||||||
|
// right here or the series renders zero-length. A present-but-backwards
|
||||||
|
// DTEND is malformed, but dropping the row would make the event un-openable
|
||||||
|
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
|
||||||
|
// surface as the generic error screen with no way to open the event and fix
|
||||||
|
// it. Clamp to a zero-length event instead (matching SearchMapper).
|
||||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin
|
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
||||||
} else {
|
} else {
|
||||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||||
if (rawEnd < begin) {
|
|
||||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
rawEnd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.time.LocalDateTime as JavaLocalDateTime
|
||||||
|
|
||||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
internal data class EventWriteTimes(
|
internal data class EventWriteTimes(
|
||||||
@@ -20,7 +22,11 @@ internal data class EventWriteTimes(
|
|||||||
/**
|
/**
|
||||||
* All-day events live at UTC midnights with an exclusive DTEND (the
|
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||||
* CalendarContract convention — a one-day event ends at the next midnight);
|
* CalendarContract convention — a one-day event ends at the next midnight);
|
||||||
* timed events resolve their wall-clock values in [zone].
|
* timed events resolve their wall-clock values in the form's own
|
||||||
|
* [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin
|
||||||
|
* one. Passing the device zone is therefore still correct for an unpinned form —
|
||||||
|
* but it no longer overrides a pinned event's zone, which is what used to
|
||||||
|
* silently re-anchor a foreign-zone event to the device on any time edit.
|
||||||
*/
|
*/
|
||||||
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
@@ -31,22 +37,56 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
timezone = "UTC",
|
timezone = "UTC",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val writeZone = writeZone(zone)
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
timezone = zone.id,
|
timezone = writeZone.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone this form's DTSTART is expressed in: UTC for an all-day event (the
|
||||||
|
* provider's date anchor), otherwise the form's own pinned zone, falling back to
|
||||||
|
* [deviceZone] when it doesn't pin one or pins something the tz database can't
|
||||||
|
* parse.
|
||||||
|
*/
|
||||||
|
private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
|
||||||
|
ZoneOffset.UTC
|
||||||
|
} else {
|
||||||
|
timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form's start as a bare wall-clock value — what the user sees on the form,
|
||||||
|
* stripped of any zone. All-day events use their date's midnight rather than
|
||||||
|
* [EventForm.start]'s placeholder time-of-day, which exists only so switching the
|
||||||
|
* event back to timed has something to show.
|
||||||
|
*/
|
||||||
|
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
||||||
|
start.date.toJavaLocalDate().atStartOfDay()
|
||||||
|
} else {
|
||||||
|
start.toJavaLocalDateTime()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC 2445 duration for a recurring event's row (the provider requires
|
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||||
* events, seconds otherwise.
|
* events, seconds otherwise.
|
||||||
*/
|
*/
|
||||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
||||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
|
||||||
|
* events (the provider's convention), seconds otherwise. Shared by the write
|
||||||
|
* paths that need a DURATION but start from a raw millisecond span (the series
|
||||||
|
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
|
||||||
|
*/
|
||||||
|
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
|
||||||
|
"P${spanMillis / MILLIS_PER_DAY}D"
|
||||||
} else {
|
} else {
|
||||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
"P${spanMillis / 1_000L}S"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,10 +135,12 @@ internal fun buildEventInsertValues(
|
|||||||
* Time fields travel together (the provider validates them as a unit):
|
* Time fields travel together (the provider validates them as a unit):
|
||||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||||
* - recurring result → the *series* DTSTART moves by the same delta the user
|
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||||
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||||
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||||
* past occurrences intact when someone edits a later occurrence's time.
|
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
||||||
|
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
||||||
|
* stable across a DST boundary or a zone change between the two.
|
||||||
*/
|
*/
|
||||||
internal fun buildEventUpdateValues(
|
internal fun buildEventUpdateValues(
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
@@ -125,10 +167,14 @@ internal fun buildEventUpdateValues(
|
|||||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A zone change counts as a time change even when the wall-clock is
|
||||||
|
// untouched: the same 09:00 in another zone is a different instant, so
|
||||||
|
// DTSTART has to move with it.
|
||||||
val timesChanged = updated.start != original.start ||
|
val timesChanged = updated.start != original.start ||
|
||||||
updated.end != original.end ||
|
updated.end != original.end ||
|
||||||
updated.isAllDay != original.isAllDay ||
|
updated.isAllDay != original.isAllDay ||
|
||||||
updated.rrule != original.rrule
|
updated.rrule != original.rrule ||
|
||||||
|
updated.timezone != original.timezone
|
||||||
if (!timesChanged) return@buildMap
|
if (!timesChanged) return@buildMap
|
||||||
|
|
||||||
val newTimes = updated.toWriteTimes(zone)
|
val newTimes = updated.toWriteTimes(zone)
|
||||||
@@ -140,8 +186,23 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.RRULE, null)
|
put(CalendarContract.Events.RRULE, null)
|
||||||
put(CalendarContract.Events.DURATION, null)
|
put(CalendarContract.Events.DURATION, null)
|
||||||
} else {
|
} else {
|
||||||
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||||
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||||
|
// zone — never by a millisecond delta. An instant delta silently bakes in
|
||||||
|
// the offset that happened to apply on the edited occurrence's date, which
|
||||||
|
// is a different offset from the series anchor's whenever a DST boundary
|
||||||
|
// sits between them, or whenever the zone itself changed. Working in wall
|
||||||
|
// clock keeps "09:00" meaning 09:00 at both ends.
|
||||||
|
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
|
||||||
|
.atZone(original.writeZone(zone)).toLocalDateTime()
|
||||||
|
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||||
|
val shifted = seriesLocal.plus(wallClockShift)
|
||||||
|
// An all-day series anchor must sit on a UTC midnight. A pure day move
|
||||||
|
// already lands there (both ends are midnights), but *switching* a
|
||||||
|
// recurring event to all-day shifts by a time-of-day too, so snap.
|
||||||
|
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
|
||||||
|
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
|
||||||
|
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
|
||||||
put(CalendarContract.Events.DTEND, null)
|
put(CalendarContract.Events.DTEND, null)
|
||||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||||
@@ -182,6 +243,215 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||||
|
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||||
|
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
|
||||||
|
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
|
||||||
|
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
|
||||||
|
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
|
||||||
|
* account, so the moved copy inherits the target calendar's colour instead.
|
||||||
|
*/
|
||||||
|
internal data class MasterEventSnapshot(
|
||||||
|
val title: String,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val dtEndMillis: Long?,
|
||||||
|
val duration: String?,
|
||||||
|
val rrule: String?,
|
||||||
|
val rdate: String?,
|
||||||
|
val exdate: String?,
|
||||||
|
val timezone: String?,
|
||||||
|
val availability: Int,
|
||||||
|
val accessLevel: Int,
|
||||||
|
val status: Int?,
|
||||||
|
val location: String?,
|
||||||
|
val description: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values re-creating [snapshot] as a fresh Events row on
|
||||||
|
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
|
||||||
|
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
|
||||||
|
* RDATE/EXDATE) so the series' generated instances — and therefore the
|
||||||
|
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
|
||||||
|
* caller layers the user's field edits on top with a normal series update.
|
||||||
|
*/
|
||||||
|
internal fun buildMovedMasterValues(
|
||||||
|
snapshot: MasterEventSnapshot,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
uid: String,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
|
||||||
|
put(CalendarContract.Events.UID_2445, uid)
|
||||||
|
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||||
|
if (snapshot.rrule != null) {
|
||||||
|
put(CalendarContract.Events.RRULE, snapshot.rrule)
|
||||||
|
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
|
||||||
|
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||||
|
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
|
||||||
|
} else {
|
||||||
|
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
|
||||||
|
}
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||||
|
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||||
|
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||||
|
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
|
||||||
|
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
|
||||||
|
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider snapshot of one exception row of a recurring series (a modified
|
||||||
|
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
|
||||||
|
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
|
||||||
|
*/
|
||||||
|
internal data class ExceptionRowSnapshot(
|
||||||
|
val exceptionEventId: Long,
|
||||||
|
val originalInstanceMillis: Long,
|
||||||
|
val isCancelled: Boolean,
|
||||||
|
val status: Int?,
|
||||||
|
val title: String,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val dtEndMillis: Long?,
|
||||||
|
val duration: String?,
|
||||||
|
val timezone: String?,
|
||||||
|
val availability: Int,
|
||||||
|
val accessLevel: Int,
|
||||||
|
val location: String?,
|
||||||
|
val description: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values replaying a *modified* occurrence [snapshot] against the moved
|
||||||
|
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
|
||||||
|
* length travels as DURATION (the provider rejects DTEND on an exception). A
|
||||||
|
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
|
||||||
|
* STATUS_CANCELED) — this builder is only for the modified case.
|
||||||
|
*/
|
||||||
|
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
|
||||||
|
buildMap {
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
|
||||||
|
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
snapshot.duration?.takeIf { it.isNotBlank() }
|
||||||
|
?: rfc2445Duration(
|
||||||
|
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
|
||||||
|
snapshot.isAllDay,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||||
|
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||||
|
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||||
|
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
||||||
|
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||||
|
* provider clone the series row and cancel exactly this one instance.
|
||||||
|
*
|
||||||
|
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
|
||||||
|
* DTSTART + DURATION so the provider derives a single instance and clears the
|
||||||
|
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
|
||||||
|
* the *whole series* is cancelled and every other occurrence disappears
|
||||||
|
* (Codeberg #47). The occurrence's length/zone come straight from the series row
|
||||||
|
* — cancelling never changes them.
|
||||||
|
*/
|
||||||
|
internal fun buildOccurrenceCancelValues(
|
||||||
|
originalInstanceMillis: Long,
|
||||||
|
dtStartMillis: Long,
|
||||||
|
duration: String?,
|
||||||
|
timezone: String?,
|
||||||
|
allDay: Int,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
||||||
|
put(CalendarContract.Events.DTSTART, dtStartMillis)
|
||||||
|
put(CalendarContract.Events.DURATION, duration)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, allDay)
|
||||||
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The master-row columns that drop the occurrence at [occurrenceMillis] from a
|
||||||
|
* series by adding it to `EXDATE` — the path for events that have **no
|
||||||
|
* `_sync_id`** (a local calendar, or a synced event not yet pushed).
|
||||||
|
*
|
||||||
|
* A cancelled exception row (see [buildOccurrenceCancelValues]) only attaches to
|
||||||
|
* its parent through `ORIGINAL_SYNC_ID`. Without a `_sync_id` the link never
|
||||||
|
* forms, and the provider's expansion of the *parent* collapses — every other
|
||||||
|
* occurrence disappears (Codeberg #47, reproduced on a local calendar). EXDATE
|
||||||
|
* needs no link, and is the canonical iCalendar way to drop an occurrence, so a
|
||||||
|
* sync adapter carries it upstream unchanged if the calendar later syncs.
|
||||||
|
*
|
||||||
|
* The whole time/recurrence set is rewritten alongside it on purpose. The
|
||||||
|
* provider does **not** treat an EXDATE-only update as a recurrence change: it
|
||||||
|
* leaves the expanded `Instances` rows untouched, so the occurrence stays visible
|
||||||
|
* (and, symmetrically, un-excluding one leaves it hidden). Writing DTSTART with
|
||||||
|
* it forces the re-expansion — but DTSTART *alone* makes the provider recompute
|
||||||
|
* `lastDate` as if the event were a single instance, collapsing the series to its
|
||||||
|
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
||||||
|
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
||||||
|
*
|
||||||
|
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
||||||
|
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
|
||||||
|
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
|
||||||
|
*/
|
||||||
|
internal fun buildOccurrenceExdateValues(
|
||||||
|
existingExdate: String?,
|
||||||
|
occurrenceMillis: Long,
|
||||||
|
dtStartMillis: Long,
|
||||||
|
rrule: String?,
|
||||||
|
duration: String?,
|
||||||
|
timezone: String?,
|
||||||
|
allDay: Int,
|
||||||
|
): Map<String, Any?> {
|
||||||
|
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||||
|
val existing = existingExdate?.split(',')
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotEmpty() }
|
||||||
|
.orEmpty()
|
||||||
|
val merged = (existing + stamp).distinct().joinToString(",")
|
||||||
|
return mapOf(
|
||||||
|
CalendarContract.Events.EXDATE to merged,
|
||||||
|
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||||
|
CalendarContract.Events.RRULE to rrule,
|
||||||
|
CalendarContract.Events.DURATION to duration,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||||
|
CalendarContract.Events.ALL_DAY to allDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||||
|
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||||
|
* reads off the UTC calendar day.
|
||||||
|
*/
|
||||||
|
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
||||||
|
return if (isAllDay) {
|
||||||
|
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
||||||
|
} else {
|
||||||
|
"%04d%02d%02dT%02d%02d%02dZ".format(
|
||||||
|
utc.year, utc.monthValue, utc.dayOfMonth,
|
||||||
|
utc.hour, utc.minute, utc.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
CalendarContract.Events.EVENT_COLOR_KEY,
|
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||||
|
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||||
|
// needs it to render a series opened without a named occurrence.
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -104,6 +107,7 @@ internal object EventDetailProjection {
|
|||||||
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
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
|
const val IDX_DURATION = 18
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,6 +190,86 @@ internal object SearchProjection {
|
|||||||
const val IDX_RDATE = 11
|
const val IDX_RDATE = 11
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The master/one-off Events row of an event about to be moved to another
|
||||||
|
* calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries
|
||||||
|
* the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy
|
||||||
|
* generates the same instances, and `UID_2445` so identity survives the move.
|
||||||
|
*/
|
||||||
|
internal object MoveMasterProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.DTEND,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.RDATE,
|
||||||
|
CalendarContract.Events.EXDATE,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_UID = 0
|
||||||
|
const val IDX_TITLE = 1
|
||||||
|
const val IDX_DTSTART = 2
|
||||||
|
const val IDX_DTEND = 3
|
||||||
|
const val IDX_DURATION = 4
|
||||||
|
const val IDX_RRULE = 5
|
||||||
|
const val IDX_RDATE = 6
|
||||||
|
const val IDX_EXDATE = 7
|
||||||
|
const val IDX_EVENT_TIMEZONE = 8
|
||||||
|
const val IDX_ALL_DAY = 9
|
||||||
|
const val IDX_AVAILABILITY = 10
|
||||||
|
const val IDX_ACCESS_LEVEL = 11
|
||||||
|
const val IDX_STATUS = 12
|
||||||
|
const val IDX_LOCATION = 13
|
||||||
|
const val IDX_DESCRIPTION = 14
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
|
||||||
|
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
|
||||||
|
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
|
||||||
|
* `DELETED = 0` so provider tombstones aren't replayed.
|
||||||
|
*/
|
||||||
|
internal object ExceptionProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.DTEND,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_ID = 0
|
||||||
|
const val IDX_ORIGINAL_INSTANCE_TIME = 1
|
||||||
|
const val IDX_STATUS = 2
|
||||||
|
const val IDX_TITLE = 3
|
||||||
|
const val IDX_ALL_DAY = 4
|
||||||
|
const val IDX_DTSTART = 5
|
||||||
|
const val IDX_DTEND = 6
|
||||||
|
const val IDX_DURATION = 7
|
||||||
|
const val IDX_EVENT_TIMEZONE = 8
|
||||||
|
const val IDX_AVAILABILITY = 9
|
||||||
|
const val IDX_ACCESS_LEVEL = 10
|
||||||
|
const val IDX_LOCATION = 11
|
||||||
|
const val IDX_DESCRIPTION = 12
|
||||||
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
@@ -10,8 +11,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
|||||||
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||||
*/
|
*/
|
||||||
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
internal fun ColumnReader.toSearchResult(): EventInstance? {
|
||||||
|
// A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
|
||||||
|
// (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
|
||||||
|
// long-running birthdays/anniversaries still surface in search.
|
||||||
|
if (isNull(SearchProjection.IDX_DTSTART)) return null
|
||||||
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
val dtStart = getLong(SearchProjection.IDX_DTSTART)
|
||||||
if (dtStart < 0L) return null
|
|
||||||
val end = when {
|
val end = when {
|
||||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
|
||||||
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
|
|
||||||
|
|
||||||
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()
|
|
||||||
@@ -4,7 +4,7 @@ import android.provider.CalendarContract
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -111,13 +111,13 @@ class SpecialDatesSyncEngine @Inject constructor(
|
|||||||
* all-day override so new events keep matching. No-op if the calendar for
|
* all-day override so new events keep matching. No-op if the calendar for
|
||||||
* [type] doesn't exist yet.
|
* [type] doesn't exist yet.
|
||||||
*/
|
*/
|
||||||
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
suspend fun applyReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||||
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||||
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||||
val minutes = when (override) {
|
val minutes = when (override) {
|
||||||
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
ReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||||
CalendarReminderOverride.None -> emptyList()
|
ReminderOverride.None -> emptyList()
|
||||||
is CalendarReminderOverride.Minutes -> override.minutes
|
is ReminderOverride.Minutes -> override.minutes
|
||||||
}
|
}
|
||||||
calendars.applyManagedCalendarReminders(
|
calendars.applyManagedCalendarReminders(
|
||||||
calendarId = calendarId,
|
calendarId = calendarId,
|
||||||
@@ -169,7 +169,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
|||||||
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||||
prefs.setCalendarAllDayReminderOverride(
|
prefs.setCalendarAllDayReminderOverride(
|
||||||
id,
|
id,
|
||||||
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
ReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.crash
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageInfo
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.content.pm.PackageInfoCompat
|
|
||||||
import java.io.File
|
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.io.StringWriter
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
|
|
||||||
* exception it writes a self-contained report to the app's private storage and
|
|
||||||
* then chains to the platform's default handler, so the process still dies
|
|
||||||
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
|
|
||||||
* the app holds no `INTERNET` permission. The user submits the report later,
|
|
||||||
* by hand, as a Gitea issue (see the ui/crash surfaces).
|
|
||||||
*
|
|
||||||
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
|
||||||
* version, locale, time, and the stack trace — and **nothing else**: no device
|
|
||||||
* identifiers, no account names, no calendar/event content, no logcat. The user
|
|
||||||
* is always shown the full text before it leaves the device.
|
|
||||||
*/
|
|
||||||
object CrashReporter {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
|
|
||||||
* also catches crashes during startup. The handler swallows nothing — it
|
|
||||||
* persists, then delegates to the previously-registered handler.
|
|
||||||
*/
|
|
||||||
fun install(context: Context) {
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
|
||||||
// Capturing must never mask the original crash, so guard every step.
|
|
||||||
runCatching {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
|
|
||||||
recordCrashTime(appContext, now)
|
|
||||||
}
|
|
||||||
previous?.uncaughtException(thread, throwable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The persisted report from the last crash, or null if there is none. */
|
|
||||||
fun pendingReport(context: Context): String? {
|
|
||||||
val file = reportFile(context)
|
|
||||||
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to surface the report unprompted (on the next launch): a report
|
|
||||||
* exists and the user hasn't already waved this one away. Settings reaches
|
|
||||||
* the report via [pendingReport] regardless, so "Not now" only stops the
|
|
||||||
* auto-prompt — it doesn't discard the report.
|
|
||||||
*/
|
|
||||||
fun shouldPrompt(context: Context): Boolean =
|
|
||||||
reportFile(context).exists() && !dismissedFile(context).exists()
|
|
||||||
|
|
||||||
/** Stop auto-prompting for the current report without discarding it. */
|
|
||||||
fun dismissPrompt(context: Context) {
|
|
||||||
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the persisted report once the user has reported it (or from Settings). */
|
|
||||||
fun clearReport(context: Context) {
|
|
||||||
runCatching { reportFile(context).delete() }
|
|
||||||
runCatching { dismissedFile(context).delete() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the app appears to be in a startup crash-loop: at least
|
|
||||||
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
|
||||||
* can't be trusted to start, so the caller routes straight to the standalone
|
|
||||||
* report screen instead of re-entering the crashing graph.
|
|
||||||
*/
|
|
||||||
fun isCrashLoop(context: Context): Boolean {
|
|
||||||
val times = readCrashTimes(context)
|
|
||||||
if (times.size < LOOP_THRESHOLD) return false
|
|
||||||
val recent = times.sortedDescending()
|
|
||||||
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the app as having started successfully, resetting the loop counter so
|
|
||||||
* an ordinary single crash much later never trips loop detection. The
|
|
||||||
* pending report itself is kept — only the timing trail is cleared.
|
|
||||||
*/
|
|
||||||
fun markHealthy(context: Context) {
|
|
||||||
runCatching { timesFile(context).delete() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- persistence -------------------------------------------------------
|
|
||||||
|
|
||||||
private fun writeReport(context: Context, report: String) {
|
|
||||||
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
|
||||||
file.writeText(report.take(MAX_REPORT_CHARS))
|
|
||||||
// A fresh crash should prompt again, even if the previous one was waved away.
|
|
||||||
runCatching { dismissedFile(context).delete() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
|
||||||
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
|
||||||
timesFile(context).apply { parentFile?.mkdirs() }
|
|
||||||
.writeText(kept.joinToString("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readCrashTimes(context: Context): List<Long> {
|
|
||||||
val file = timesFile(context)
|
|
||||||
if (!file.exists()) return emptyList()
|
|
||||||
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
|
||||||
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
|
||||||
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
|
||||||
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
|
||||||
|
|
||||||
private const val CRASH_DIR = "crash"
|
|
||||||
private const val REPORT_FILE = "last_crash.txt"
|
|
||||||
private const val TIMES_FILE = "crash_times.txt"
|
|
||||||
private const val DISMISSED_FILE = "dismissed"
|
|
||||||
private const val MAX_TIMES = 5
|
|
||||||
private const val MAX_REPORT_CHARS = 64 * 1024
|
|
||||||
private const val LOOP_THRESHOLD = 2
|
|
||||||
private const val LOOP_WINDOW_MS = 10_000L
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The allowlist of non-personal facts that go into a crash report. Built from
|
|
||||||
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
|
||||||
*/
|
|
||||||
data class CrashContext(
|
|
||||||
val appVersionName: String,
|
|
||||||
val appVersionCode: Long,
|
|
||||||
val sdkInt: Int,
|
|
||||||
val androidRelease: String,
|
|
||||||
val manufacturer: String,
|
|
||||||
val model: String,
|
|
||||||
val locale: String,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(context: Context): CrashContext {
|
|
||||||
val pkg = runCatching {
|
|
||||||
context.packageManager.getPackageInfo(context.packageName, 0)
|
|
||||||
}.getOrNull()
|
|
||||||
return CrashContext(
|
|
||||||
appVersionName = pkg?.versionName ?: "?",
|
|
||||||
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
|
||||||
sdkInt = Build.VERSION.SDK_INT,
|
|
||||||
androidRelease = Build.VERSION.RELEASE ?: "?",
|
|
||||||
manufacturer = Build.MANUFACTURER ?: "?",
|
|
||||||
model = Build.MODEL ?: "?",
|
|
||||||
locale = Locale.getDefault().toLanguageTag(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
|
||||||
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
|
|
||||||
* tested. The leading marker doubles as the file's sanity check in
|
|
||||||
* [CrashReporter.pendingReport].
|
|
||||||
*/
|
|
||||||
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
|
|
||||||
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
|
||||||
val time = runCatching {
|
|
||||||
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
|
||||||
}.getOrDefault(nowMillis.toString())
|
|
||||||
return buildString {
|
|
||||||
appendLine("Calendula crash report")
|
|
||||||
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
|
||||||
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
|
||||||
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
|
||||||
appendLine("Locale: ${ctx.locale}")
|
|
||||||
appendLine("Time: $time")
|
|
||||||
appendLine()
|
|
||||||
appendLine("Stack trace:")
|
|
||||||
append(trace)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
|
||||||
@@ -7,6 +7,11 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
@@ -16,9 +21,15 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import java.time.temporal.WeekFields
|
import java.time.temporal.WeekFields
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -76,6 +87,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
|||||||
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolved first day of the week as a hot [StateFlow] — the one shape every
|
||||||
|
* screen that orders or lays out weekdays should use (the month grid, the agenda
|
||||||
|
* "this week" range, the recurrence weekday toggles). Sharing it keeps the
|
||||||
|
* initial-frame value identical everywhere; hand-rolled copies had drifted onto
|
||||||
|
* different `initialValue`s, so two surfaces could disagree for a frame.
|
||||||
|
*/
|
||||||
|
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
|
||||||
|
weekStart
|
||||||
|
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||||
|
.stateIn(
|
||||||
|
scope = scope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
// Seed with the locale convention rather than a hardcoded weekday, so
|
||||||
|
// the first frame is already right for everyone still on Auto.
|
||||||
|
initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display settings (M4) persisted app-side: theme override, Material You
|
* Display settings (M4) persisted app-side: theme override, Material You
|
||||||
* dynamic colour, and week start. Language is handled separately through
|
* dynamic colour, and week start. Language is handled separately through
|
||||||
@@ -109,6 +138,21 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether raw provider colours are softened toward theme-fitting pastels
|
||||||
|
* before display (issue #36). Defaults to ON — the historical look, which
|
||||||
|
* caps harsh sync colours and pins brightness so entries read on both
|
||||||
|
* themes. Turning it off paints the calendar/event colour exactly as the
|
||||||
|
* sync source (DAVx5/CalDAV) publishes it.
|
||||||
|
*/
|
||||||
|
val softenCalendarColors: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[SOFTEN_COLORS_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSoftenCalendarColors(enabled: Boolean) {
|
||||||
|
store.edit { it[SOFTEN_COLORS_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
||||||
* strings — "system", "custom", or a bundled font's token — resolved to a
|
* strings — "system", "custom", or a bundled font's token — resolved to a
|
||||||
@@ -200,6 +244,46 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the Month grid shows the calendar-week (ISO) number in a left
|
||||||
|
* gutter (#25). Defaults to OFF — users opt in, since it narrows the day
|
||||||
|
* cells slightly. The Week view shows its number unconditionally.
|
||||||
|
*/
|
||||||
|
val showWeekNumbers: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[SHOW_WEEK_NUMBERS_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setShowWeekNumbers(enabled: Boolean) {
|
||||||
|
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Month view lays itself out (#38, #53). Defaults to [MonthViewStyle.Paged]
|
||||||
|
* — the historical behaviour, so existing installs see no change until they opt in.
|
||||||
|
*/
|
||||||
|
val monthViewStyle: Flow<MonthViewStyle> = store.data.map { prefs ->
|
||||||
|
prefs[MONTH_VIEW_STYLE_KEY].toEnum(MonthViewStyle.Paged)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMonthViewStyle(style: MonthViewStyle) {
|
||||||
|
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||||
|
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||||
|
* only while the view is off today. Turning it ON moves it to a persistent
|
||||||
|
* icon button in each calendar view's top bar (always shown, on today or
|
||||||
|
* not) and drops the FAB pill.
|
||||||
|
*/
|
||||||
|
val todayButtonInToolbar: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[TODAY_BUTTON_IN_TOOLBAR_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTodayButtonInToolbar(enabled: Boolean) {
|
||||||
|
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||||
@@ -234,6 +318,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the agenda (both the in-app screen and the home-screen widget)
|
||||||
|
* always anchors today at the top with a "nothing left today" placeholder,
|
||||||
|
* even once today has no remaining events (issue #35). Default ON — makes
|
||||||
|
* today's events easy to tell apart from a future day's at a glance.
|
||||||
|
*/
|
||||||
|
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAgendaShowToday(enabled: Boolean) {
|
||||||
|
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||||
* the historical hard-coded startup view — so existing users see no change
|
* the historical hard-coded startup view — so existing users see no change
|
||||||
@@ -307,6 +405,26 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zones the user picked recently, most recent first — the timezone picker's
|
||||||
|
* default list, since scrolling ~600 zones to re-find the two you actually
|
||||||
|
* use is the whole problem. Stored comma-joined (IANA ids never contain a
|
||||||
|
* comma); unparseable ids are dropped on read, so a zone the tz database
|
||||||
|
* later retires can't wedge the list.
|
||||||
|
*/
|
||||||
|
val recentTimeZones: Flow<List<String>> = store.data.map { prefs ->
|
||||||
|
parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addRecentTimeZone(zoneId: String) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]))
|
||||||
|
.distinct()
|
||||||
|
.take(MAX_RECENT_TIME_ZONES)
|
||||||
|
prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether opening the new-event form focuses the title field and raises the
|
* Whether opening the new-event form focuses the title field and raises the
|
||||||
* keyboard straight away (issue #10). Default ON — a new event almost always
|
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||||
@@ -436,14 +554,14 @@ class SettingsPrefs @Inject constructor(
|
|||||||
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||||
*/
|
*/
|
||||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
current.applyOverride(calendarId, override)
|
current.applyReminderOverride(calendarId, override)
|
||||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,18 +571,18 @@ class SettingsPrefs @Inject constructor(
|
|||||||
* inherit the global all-day default; present null = no reminder).
|
* inherit the global all-day default; present null = no reminder).
|
||||||
*/
|
*/
|
||||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setCalendarAllDayReminderOverride(
|
suspend fun setCalendarAllDayReminderOverride(
|
||||||
calendarId: Long,
|
calendarId: Long,
|
||||||
override: CalendarReminderOverride,
|
override: ReminderOverride,
|
||||||
) {
|
) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
val current =
|
val current =
|
||||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
current.applyOverride(calendarId, override)
|
current.applyReminderOverride(calendarId, override)
|
||||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,6 +721,13 @@ class SettingsPrefs @Inject constructor(
|
|||||||
private fun titleTemplateKey(type: SpecialDateType) =
|
private fun titleTemplateKey(type: SpecialDateType) =
|
||||||
stringPreferencesKey("special_dates_title_${type.name}")
|
stringPreferencesKey("special_dates_title_${type.name}")
|
||||||
|
|
||||||
|
private fun parseRecentTimeZones(stored: String?): List<String> =
|
||||||
|
stored?.split(',').orEmpty()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess }
|
||||||
|
.distinct()
|
||||||
|
.take(MAX_RECENT_TIME_ZONES)
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -647,6 +772,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
|
internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors")
|
||||||
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||||
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
||||||
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
||||||
@@ -655,10 +781,15 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||||
|
internal val AGENDA_SHOW_TODAY_KEY =
|
||||||
|
booleanPreferencesKey("agenda_show_today")
|
||||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
|
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||||
|
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||||
|
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
@@ -685,6 +816,10 @@ class SettingsPrefs @Inject constructor(
|
|||||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
|
internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones")
|
||||||
|
|
||||||
|
/** Enough to cover the zones a user actually recurs to, without a wall of rows. */
|
||||||
|
internal const val MAX_RECENT_TIME_ZONES = 5
|
||||||
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||||
@@ -727,28 +862,6 @@ data class BackupStatus(
|
|||||||
val consecutiveFailures: Int,
|
val consecutiveFailures: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
|
||||||
sealed interface CalendarReminderOverride {
|
|
||||||
/** No override — the calendar uses the global default. */
|
|
||||||
data object Inherit : CalendarReminderOverride
|
|
||||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
|
||||||
data object None : CalendarReminderOverride
|
|
||||||
/** Specific lead times in minutes before the event start (non-empty). */
|
|
||||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stored override for [calendarId] in an override map, as a
|
|
||||||
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
|
|
||||||
* empty → [CalendarReminderOverride.None]) — the inverse of how
|
|
||||||
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
|
|
||||||
*/
|
|
||||||
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
|
||||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
|
||||||
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
|
||||||
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lead times to prefill on a new event: the matching per-calendar override
|
* The lead times to prefill on a new event: the matching per-calendar override
|
||||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
@@ -773,21 +886,6 @@ fun resolveDefaultReminder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
|
||||||
* key; [None] and an empty [Minutes] both store the empty list).
|
|
||||||
*/
|
|
||||||
private fun MutableMap<Long, List<Int>>.applyOverride(
|
|
||||||
calendarId: Long,
|
|
||||||
override: CalendarReminderOverride,
|
|
||||||
) {
|
|
||||||
when (override) {
|
|
||||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
|
||||||
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
|
||||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||||
private const val WEEK_START_AUTO = "AUTO"
|
private const val WEEK_START_AUTO = "AUTO"
|
||||||
|
|
||||||
@@ -812,8 +910,14 @@ private const val ENTRY_SEP = ";"
|
|||||||
private const val KEY_VALUE_SEP = "="
|
private const val KEY_VALUE_SEP = "="
|
||||||
private const val LIST_SEP = ","
|
private const val LIST_SEP = ","
|
||||||
|
|
||||||
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
/**
|
||||||
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
* The per-calendar override map codec, in Calendula's stored dialect
|
||||||
|
* (`id=minutes` entries joined by `;`, minutes comma-joined, `none` for an
|
||||||
|
* explicit no-reminder). The model and codec live in floret-kit; the dialect
|
||||||
|
* (fixed at release) stays here.
|
||||||
|
*/
|
||||||
|
private val reminderOverrideCodec =
|
||||||
|
ReminderOverrideCodec(entrySep = ENTRY_SEP, keyValueSep = KEY_VALUE_SEP, listSep = LIST_SEP, noneToken = NONE)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||||
@@ -829,25 +933,6 @@ private fun String?.toReminderList(): List<Int> = when {
|
|||||||
private fun List<Int>.toStoredReminders(): String =
|
private fun List<Int>.toStoredReminders(): String =
|
||||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||||
|
|
||||||
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
|
||||||
if (stored.isNullOrBlank()) return emptyMap()
|
|
||||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
|
||||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
|
||||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
|
||||||
// Only the deliberate "none" sentinel means an explicit no-reminder
|
|
||||||
// override (empty list); a non-sentinel value that parses to no valid
|
|
||||||
// minutes is garbage and drops the entry, so the calendar inherits the
|
|
||||||
// global default rather than silently reading as "no reminder".
|
|
||||||
when (val value = parts[1]) {
|
|
||||||
NONE -> id to emptyList()
|
|
||||||
else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it }
|
|
||||||
?: return@mapNotNull null
|
|
||||||
}
|
|
||||||
}.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
|
||||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.isoDayNumber
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -55,13 +59,22 @@ class ReminderNotifier @Inject constructor(
|
|||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
||||||
|
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
||||||
|
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||||
val time = reminderTimeText(
|
val time = reminderTimeText(
|
||||||
beginMillis = alert.beginMillis,
|
beginMillis = alert.beginMillis,
|
||||||
endMillis = alert.endMillis,
|
endMillis = alert.endMillis,
|
||||||
isAllDay = alert.isAllDay,
|
isAllDay = alert.isAllDay,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = zone,
|
||||||
locale = Locale.getDefault(),
|
locale = locale,
|
||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
|
today = Instant.now().atZone(zone).toLocalDate(),
|
||||||
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
|
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||||
|
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||||
)
|
)
|
||||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
|
import java.time.DayOfWeek
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one line of time context in a reminder notification. Pure so it can be
|
* The one line of time context in a reminder notification. Pure so it can be
|
||||||
* JVM-tested:
|
* JVM-tested.
|
||||||
*
|
*
|
||||||
* - timed, same day: "09:30 – 10:00"
|
* Timed events that fall on a day other than [today] are prefixed with that
|
||||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
* day, so a reminder fired ahead of time no longer reads as if the event were
|
||||||
|
* today (issue #46). The prefix prefers natural language and stays short:
|
||||||
|
*
|
||||||
|
* - today: "09:30 – 10:00" (no prefix)
|
||||||
|
* - tomorrow / yesterday: "Tomorrow, 09:30 – 10:00" ([tomorrowLabel] / [yesterdayLabel])
|
||||||
|
* - elsewhere this week: "Thu, 09:30 – 10:00" (localized short weekday)
|
||||||
|
* - further out: "16 Jul, 09:30 – 10:00" (medium date — a weekday
|
||||||
|
* alone would be ambiguous)
|
||||||
|
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||||
|
* already unambiguous)
|
||||||
* - all-day, one day: "11 Jun 2026"
|
* - all-day, one day: "11 Jun 2026"
|
||||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||||
*
|
*
|
||||||
* All-day instances store UTC midnights with an exclusive end, so they are
|
* All-day instances already carry an explicit date, so they never gain a
|
||||||
|
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
||||||
* read in UTC and the end day is the last *covered* day.
|
* read in UTC and the end day is the last *covered* day.
|
||||||
*/
|
*/
|
||||||
fun reminderTimeText(
|
fun reminderTimeText(
|
||||||
@@ -27,6 +41,10 @@ fun reminderTimeText(
|
|||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
|
today: LocalDate,
|
||||||
|
firstDayOfWeek: DayOfWeek,
|
||||||
|
tomorrowLabel: String,
|
||||||
|
yesterdayLabel: String,
|
||||||
): String {
|
): String {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
@@ -43,18 +61,70 @@ fun reminderTimeText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||||
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||||
|
val prefix = relativeDayPrefix(
|
||||||
|
day = begin.toLocalDate(),
|
||||||
|
today = today,
|
||||||
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
|
locale = locale,
|
||||||
|
dateFormat = dateFormat,
|
||||||
|
tomorrowLabel = tomorrowLabel,
|
||||||
|
yesterdayLabel = yesterdayLabel,
|
||||||
|
)
|
||||||
|
if (prefix == null) range else "$prefix, $range"
|
||||||
} else {
|
} else {
|
||||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||||
// applies to the time portion too.
|
// applies to the time portion too. The explicit dates already say which
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
// day, so no relative prefix is layered on top.
|
||||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||||
dateTime(begin) + RANGE + dateTime(end)
|
dateTime(begin) + RANGE + dateTime(end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short label for [day] relative to [today], or `null` when it *is* today (the
|
||||||
|
* common case, which needs no prefix). Weekday names are used only within the
|
||||||
|
* current week — a "next Wednesday" would be indistinguishable from this one, so
|
||||||
|
* anything past this week falls back to the exact date.
|
||||||
|
*/
|
||||||
|
private fun relativeDayPrefix(
|
||||||
|
day: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
firstDayOfWeek: DayOfWeek,
|
||||||
|
locale: Locale,
|
||||||
|
dateFormat: DateTimeFormatter,
|
||||||
|
tomorrowLabel: String,
|
||||||
|
yesterdayLabel: String,
|
||||||
|
): String? = when (ChronoUnit.DAYS.between(today, day)) {
|
||||||
|
0L -> null
|
||||||
|
1L -> tomorrowLabel
|
||||||
|
-1L -> yesterdayLabel
|
||||||
|
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
|
||||||
|
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
|
||||||
|
} else {
|
||||||
|
dateFormat.format(day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [day] and [today] share the same week. The week boundary honours the
|
||||||
|
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
|
||||||
|
* with [firstDayOfWeek] falling back to the locale default upstream).
|
||||||
|
*/
|
||||||
|
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
|
||||||
|
val startOfWeek = today.previousOrSame(firstDayOfWeek)
|
||||||
|
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most recent [target] on or before this date (this date itself when it matches). */
|
||||||
|
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
|
||||||
|
val backtrack = (dayOfWeek.value - target.value + 7) % 7
|
||||||
|
return minusDays(backtrack.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
private const val RANGE = " – "
|
private const val RANGE = " – "
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cbrt
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curates an account's published event palette for the colour picker.
|
||||||
|
*
|
||||||
|
* Sync adapters differ wildly in what they publish: Google exposes a
|
||||||
|
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
|
||||||
|
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
|
||||||
|
* spelling pairs) and dozens of visually indistinguishable whites and grays
|
||||||
|
* (#22).
|
||||||
|
*
|
||||||
|
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||||
|
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
||||||
|
* it pins lightness to a constant and caps saturation, so the raw palette's
|
||||||
|
* lightness axis is invisible on screen. Two raw colours that look different —
|
||||||
|
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
||||||
|
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
||||||
|
* space, as before, left near-identical painted swatches and stranded the
|
||||||
|
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
||||||
|
*
|
||||||
|
* Three steps, all in painted space:
|
||||||
|
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||||
|
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||||
|
* hue, and all the neutrals together.
|
||||||
|
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
||||||
|
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
||||||
|
* then thinned to visually distinct colours: most vivid first, a colour is
|
||||||
|
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
||||||
|
* colour already kept. Small palettes are already curated by their adapter
|
||||||
|
* and pass through whole.
|
||||||
|
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||||
|
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||||
|
* seam lands in dead space and no hue family is torn across both ends.
|
||||||
|
*
|
||||||
|
* Every surviving option keeps its provider [EventColorOption.key], so a pick
|
||||||
|
* still round-trips through sync.
|
||||||
|
*/
|
||||||
|
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||||
|
val painted = sortedBy { it.key }
|
||||||
|
.distinctBy { pastelArgb(it.argb) }
|
||||||
|
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||||
|
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||||
|
painted
|
||||||
|
} else {
|
||||||
|
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||||
|
}
|
||||||
|
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders swatches continuously around the (painted) hue wheel, then cuts the
|
||||||
|
* circle at its widest angular gap so the single seam lands in empty space
|
||||||
|
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||||
|
*/
|
||||||
|
private fun orderAroundWheel(
|
||||||
|
swatches: List<Pair<EventColorOption, Lab>>,
|
||||||
|
): List<Pair<EventColorOption, Lab>> {
|
||||||
|
if (swatches.size < 2) return swatches
|
||||||
|
val byHue = swatches.sortedWith(
|
||||||
|
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
||||||
|
)
|
||||||
|
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||||
|
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||||
|
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||||
|
var cutAfter = byHue.lastIndex
|
||||||
|
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
||||||
|
for (i in 0 until byHue.lastIndex) {
|
||||||
|
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||||
|
if (gap > widestGap) {
|
||||||
|
widestGap = gap
|
||||||
|
cutAfter = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||||
|
private fun thin(
|
||||||
|
swatches: List<Pair<EventColorOption, Lab>>,
|
||||||
|
): List<Pair<EventColorOption, Lab>> {
|
||||||
|
val byVividness = swatches
|
||||||
|
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||||
|
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||||
|
for (candidate in byVividness) {
|
||||||
|
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The softening the colour picker paints over every swatch: keep the hue, scale
|
||||||
|
* and clamp saturation into a gentle band, and pin value to a constant so
|
||||||
|
* nothing screams and everything reads on the surface. Value is fixed here so
|
||||||
|
* curation is theme-independent — only hue and saturation distinguish painted
|
||||||
|
* swatches.
|
||||||
|
*
|
||||||
|
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
|
||||||
|
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
|
||||||
|
* rather than theme-picked. Curation must reason about the colour the picker
|
||||||
|
* paints, so the two shapings have to agree: if floret's saturation band or
|
||||||
|
* curve changes, update this in step.
|
||||||
|
*/
|
||||||
|
fun pastelArgb(rawArgb: Int): Int {
|
||||||
|
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
||||||
|
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
||||||
|
val b = (rawArgb and 0xFF) / 255f
|
||||||
|
val max = maxOf(r, g, b)
|
||||||
|
val min = minOf(r, g, b)
|
||||||
|
val delta = max - min
|
||||||
|
val hue = when {
|
||||||
|
delta == 0f -> 0f
|
||||||
|
max == r -> 60f * (((g - b) / delta) % 6f)
|
||||||
|
max == g -> 60f * (((b - r) / delta) + 2f)
|
||||||
|
else -> 60f * (((r - g) / delta) + 4f)
|
||||||
|
}.let { if (it < 0f) it + 360f else it }
|
||||||
|
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
||||||
|
val s = sat.coerceIn(0.25f, 0.65f)
|
||||||
|
val v = PASTEL_VALUE
|
||||||
|
val c = v * s
|
||||||
|
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
||||||
|
val m = v - c
|
||||||
|
val (rr, gg, bb) = when {
|
||||||
|
hue < 60f -> Triple(c, x, 0f)
|
||||||
|
hue < 120f -> Triple(x, c, 0f)
|
||||||
|
hue < 180f -> Triple(0f, c, x)
|
||||||
|
hue < 240f -> Triple(0f, x, c)
|
||||||
|
hue < 300f -> Triple(x, 0f, c)
|
||||||
|
else -> Triple(c, 0f, x)
|
||||||
|
}
|
||||||
|
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
||||||
|
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
||||||
|
private const val PASTEL_VALUE = 0.82f
|
||||||
|
|
||||||
|
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||||
|
private const val CURATION_TRIGGER_SIZE = 36
|
||||||
|
|
||||||
|
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
||||||
|
private const val MIN_DELTA_E = 13.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
||||||
|
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
||||||
|
* is dropped rather than shown as pale filler.
|
||||||
|
*/
|
||||||
|
private const val PASTEL_CHROMA_FLOOR = 22.0
|
||||||
|
|
||||||
|
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||||
|
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||||
|
val chroma: Double get() = hypot(a, b)
|
||||||
|
|
||||||
|
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
||||||
|
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||||
|
|
||||||
|
fun deltaE(other: Lab): Double =
|
||||||
|
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(argb: Int): Lab {
|
||||||
|
fun linear(shift: Int): Double {
|
||||||
|
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||||
|
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||||
|
}
|
||||||
|
val r = linear(16)
|
||||||
|
val g = linear(8)
|
||||||
|
val b = linear(0)
|
||||||
|
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||||
|
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||||
|
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||||
|
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
||||||
|
val fy = f(y)
|
||||||
|
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||||
* wall-clock values in the device zone; the data layer translates them to
|
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||||
* provider millis (all-day events normalise to UTC midnights there).
|
* millis (all-day events normalise to UTC midnights there).
|
||||||
*/
|
*/
|
||||||
data class EventForm(
|
data class EventForm(
|
||||||
val calendarId: Long?,
|
val calendarId: Long?,
|
||||||
@@ -18,6 +19,23 @@ data class EventForm(
|
|||||||
val isAllDay: Boolean = false,
|
val isAllDay: Boolean = false,
|
||||||
val start: LocalDateTime,
|
val start: LocalDateTime,
|
||||||
val end: LocalDateTime,
|
val end: LocalDateTime,
|
||||||
|
/**
|
||||||
|
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
||||||
|
* device — null is not "no zone", it is "whichever zone the device is in
|
||||||
|
* when this is saved", which is what an event authored and lived in one
|
||||||
|
* place wants. The data layer resolves it at write time and always stamps a
|
||||||
|
* concrete `EVENT_TIMEZONE`.
|
||||||
|
*
|
||||||
|
* A non-null value pins the event to a zone regardless of where the device
|
||||||
|
* is, so it keeps tracking that zone's offset across DST. [toEditForm] only
|
||||||
|
* sets it when the stored zone differs from the device's, so merely opening
|
||||||
|
* a local event never reveals the field — and re-opening a pinned one in
|
||||||
|
* another zone round-trips it rather than silently re-anchoring it.
|
||||||
|
*
|
||||||
|
* Always null for all-day events: those are date-anchored, not zone-anchored
|
||||||
|
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
|
||||||
|
*/
|
||||||
|
val timezone: String? = null,
|
||||||
val location: String = "",
|
val location: String = "",
|
||||||
val description: String = "",
|
val description: String = "",
|
||||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||||
@@ -66,11 +84,19 @@ data class EventAttendee(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The form's optional sections. Which ones show by default is a user setting;
|
* The form's optional sections. Which ones show by default is a user setting;
|
||||||
* the rest unfold behind a "more fields" button.
|
* the rest unfold behind a "more fields" button. Declaration order is the order
|
||||||
|
* they're offered in, so a new constant goes where it belongs on the form, not
|
||||||
|
* at the end.
|
||||||
*/
|
*/
|
||||||
enum class EventFormField {
|
enum class EventFormField {
|
||||||
Location,
|
Location,
|
||||||
Description,
|
Description,
|
||||||
|
/**
|
||||||
|
* Pins the event's wall-clock times to a zone. Offered right after the time
|
||||||
|
* fields it qualifies, and suppressed entirely for all-day events, whose
|
||||||
|
* dates are deliberately zone-free.
|
||||||
|
*/
|
||||||
|
Timezone,
|
||||||
Reminders,
|
Reminders,
|
||||||
Recurrence,
|
Recurrence,
|
||||||
Availability,
|
Availability,
|
||||||
@@ -100,8 +126,25 @@ enum class EventFormProblem {
|
|||||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||||
* shows the last covered day and keeps placeholder wall-clock times in case
|
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||||
* the user switches the event to timed.
|
* the user switches the event to timed.
|
||||||
|
*
|
||||||
|
* A timed event stored in a zone other than [zone] is prefilled *in its own
|
||||||
|
* zone* and keeps it pinned, so the wall-clock the form shows is the one the
|
||||||
|
* event means ("the New York 09:00 call") and a later save re-anchors it to the
|
||||||
|
* same zone rather than the device's.
|
||||||
*/
|
*/
|
||||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||||
|
// All-day events are date-anchored and carry a nominal "UTC" that is an
|
||||||
|
// anchor, not a location, so they never pin a zone.
|
||||||
|
val pinnedZone = if (instance.isAllDay) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
eventTimezone
|
||||||
|
?.takeIf { it != zone.id }
|
||||||
|
// An unparseable id (a malformed sync row) can't be honoured or
|
||||||
|
// shown; fall back to the device zone rather than failing the open.
|
||||||
|
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
||||||
|
}
|
||||||
|
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
||||||
val (start, end) = if (instance.isAllDay) {
|
val (start, end) = if (instance.isAllDay) {
|
||||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||||
.toLocalDateTime(TimeZone.UTC).date
|
.toLocalDateTime(TimeZone.UTC).date
|
||||||
@@ -110,8 +153,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
} else {
|
} else {
|
||||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||||
}
|
}
|
||||||
return EventForm(
|
return EventForm(
|
||||||
calendarId = instance.calendarId,
|
calendarId = instance.calendarId,
|
||||||
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
isAllDay = instance.isAllDay,
|
isAllDay = instance.isAllDay,
|
||||||
start = start,
|
start = start,
|
||||||
end = end,
|
end = end,
|
||||||
|
timezone = pinnedZone,
|
||||||
location = instance.location.orEmpty(),
|
location = instance.location.orEmpty(),
|
||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||||
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
rowEnd = instance.end,
|
rowEnd = instance.end,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form's times as they land in [target] — what a pinned event's wall-clock
|
||||||
|
* actually means where the user is standing. Null when there's nothing to
|
||||||
|
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
|
||||||
|
* itself, an all-day event (no zone), or an unparseable pinned zone.
|
||||||
|
*
|
||||||
|
* The form edits a pinned event in its own zone, so this is what lets the UI
|
||||||
|
* show the other side of the pair rather than making the user do the arithmetic.
|
||||||
|
*/
|
||||||
|
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
|
||||||
|
if (isAllDay) return null
|
||||||
|
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
|
||||||
|
if (pinned.id == target.id) return null
|
||||||
|
return start.toInstant(pinned).toLocalDateTime(target) to
|
||||||
|
end.toInstant(pinned).toLocalDateTime(target)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The optional sections that hold a value in [form] — when editing, these
|
* The optional sections that hold a value in [form] — when editing, these
|
||||||
* must be visible regardless of the user's default-fields setting, or the
|
* must be visible regardless of the user's default-fields setting, or the
|
||||||
@@ -184,6 +245,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||||
if (location.isNotBlank()) add(EventFormField.Location)
|
if (location.isNotBlank()) add(EventFormField.Location)
|
||||||
if (description.isNotBlank()) add(EventFormField.Description)
|
if (description.isNotBlank()) add(EventFormField.Description)
|
||||||
|
if (timezone != null) add(EventFormField.Timezone)
|
||||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||||
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)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a prefilled [EventForm] from an `ACTION_INSERT` intent's extras (issue
|
||||||
|
* #30). External apps and widgets (e.g. the Todo Agenda widget) launch the
|
||||||
|
* calendar this way to create a new event, passing the fields as
|
||||||
|
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||||
|
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||||
|
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
|
||||||
|
* it resolves to the last-used / first-writable calendar, exactly like the
|
||||||
|
* `.ics` single-event and plain new-event paths.
|
||||||
|
*
|
||||||
|
* Pure (no Android types) so it is unit-testable; the intent parsing that reads
|
||||||
|
* the extras lives in `MainActivity.insertFormOrNull`.
|
||||||
|
*/
|
||||||
|
fun buildInsertEventForm(
|
||||||
|
beginMillis: Long?,
|
||||||
|
endMillis: Long?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
title: String?,
|
||||||
|
description: String?,
|
||||||
|
location: String?,
|
||||||
|
rrule: String?,
|
||||||
|
zone: TimeZone,
|
||||||
|
now: Instant,
|
||||||
|
): EventForm {
|
||||||
|
val (start, end) = if (isAllDay) {
|
||||||
|
// All-day provider times are UTC midnights with an exclusive end; show
|
||||||
|
// the last covered day and keep placeholder wall-clock times in case the
|
||||||
|
// user switches the event to timed (mirrors EventDetail.toEditForm).
|
||||||
|
val startDate = beginMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||||
|
?: now.toLocalDateTime(zone).date
|
||||||
|
val endDate = endMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date }
|
||||||
|
?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) }
|
||||||
|
?: startDate
|
||||||
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
|
} else {
|
||||||
|
val startTime = beginMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||||
|
?: nextFullHour(now, zone)
|
||||||
|
val endTime = endMillis
|
||||||
|
?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) }
|
||||||
|
?.takeIf { it >= startTime }
|
||||||
|
?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||||
|
startTime to endTime
|
||||||
|
}
|
||||||
|
return EventForm(
|
||||||
|
calendarId = null,
|
||||||
|
title = title.orEmpty(),
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
location = location.orEmpty(),
|
||||||
|
description = description.orEmpty(),
|
||||||
|
// Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:"
|
||||||
|
// some callers include.
|
||||||
|
rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime {
|
||||||
|
val hourMillis = 3_600_000L
|
||||||
|
val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis
|
||||||
|
return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone)
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import java.text.Normalizer
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable zone, resolved for display at a given instant. [id] is the IANA
|
||||||
|
* id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
|
||||||
|
* [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
|
||||||
|
* [offsetMinutes] its offset. Both the abbreviation and the offset shift with
|
||||||
|
* DST, so they're only meaningful next to the moment they were resolved for.
|
||||||
|
*
|
||||||
|
* [displayName] is the long localized name ("Central European Time"). It's kept
|
||||||
|
* for search only — matching on it lets someone type "pacific" — and is *not*
|
||||||
|
* displayed: spelled out next to the id it made the field too wide to fit.
|
||||||
|
*/
|
||||||
|
data class TimeZoneOption(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val shortName: String,
|
||||||
|
val offsetMinutes: Int,
|
||||||
|
) {
|
||||||
|
/** The id as the primary label, underscores undone ("America/New York"). */
|
||||||
|
val label: String get() = id.replace('_', ' ')
|
||||||
|
|
||||||
|
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
|
||||||
|
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
|
||||||
|
|
||||||
|
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
|
||||||
|
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four fields [filterTimeZones] matches on, normalized once here rather
|
||||||
|
* than per query. Normalizing is not cheap — NFD decomposition plus a combining-
|
||||||
|
* mark strip — and a search re-examines every option on every keystroke, so
|
||||||
|
* doing it at construction turns ~2400 normalizations per character into a few
|
||||||
|
* hundred plain `startsWith`/`contains` calls.
|
||||||
|
*
|
||||||
|
* Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`:
|
||||||
|
* it is derived state, and two options with the same id are the same option.
|
||||||
|
*/
|
||||||
|
internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys(
|
||||||
|
city = city.normalizeForSearch(),
|
||||||
|
id = id.normalizeForSearch(),
|
||||||
|
displayName = displayName.normalizeForSearch(),
|
||||||
|
shortName = shortName.normalizeForSearch(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-normalized match targets for one [TimeZoneOption]. */
|
||||||
|
internal data class TimeZoneSearchKeys(
|
||||||
|
val city: String,
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val shortName: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun optionFor(
|
||||||
|
zone: ZoneId,
|
||||||
|
locale: Locale,
|
||||||
|
at: Instant,
|
||||||
|
regionOf: (String) -> String?,
|
||||||
|
): TimeZoneOption = TimeZoneOption(
|
||||||
|
id = zone.id,
|
||||||
|
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||||
|
shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf),
|
||||||
|
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or
|
||||||
|
* a "GMT+05:30" form when no name exists.
|
||||||
|
*
|
||||||
|
* Two things make this fiddlier than it looks. First, it goes through
|
||||||
|
* java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has
|
||||||
|
* no short specific-zone names and degrades every zone to a "GMT-4" form (it
|
||||||
|
* agrees with java.util only on desktop, which is why desktop can't catch it).
|
||||||
|
* Second, ICU only surfaces the short name *commonly used in the display
|
||||||
|
* locale's region* — a German-region English phone (en-DE) is shown "CEST" but
|
||||||
|
* not "EDT", and an en-US phone the reverse. So ask in the display *language*
|
||||||
|
* but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in
|
||||||
|
* en-DE) via [regionOf]. That covers almost everything; where a region still
|
||||||
|
* yields no name (Athens in en-GR) the plain device locale sometimes does, so
|
||||||
|
* try that next; failing both, the caller shows the offset.
|
||||||
|
*
|
||||||
|
* [regionOf] maps an IANA id to an ISO 3166 region and is injected because it
|
||||||
|
* needs `android.icu`, which this pure-JVM module can't import. The default
|
||||||
|
* (no region) leaves only the device-locale path — which is all a JVM test has
|
||||||
|
* anyway, and enough there since desktop ICU isn't region-gated the same way.
|
||||||
|
*/
|
||||||
|
private fun resolveAbbreviation(
|
||||||
|
id: String,
|
||||||
|
dst: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
regionOf: (String) -> String?,
|
||||||
|
): String {
|
||||||
|
fun shortIn(loc: Locale): String =
|
||||||
|
java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc)
|
||||||
|
val regional = regionOf(id)
|
||||||
|
?.takeIf { it.length == 2 }
|
||||||
|
?.let { shortIn(Locale(locale.language, it)) }
|
||||||
|
if (regional != null && !regional.looksLikeOffset()) return regional
|
||||||
|
// Either no region, or the region has no name for this zone: the device
|
||||||
|
// locale is the next-best shot, and the offset the last resort.
|
||||||
|
return shortIn(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for the offset-style names ICU returns when a zone has no abbreviation. */
|
||||||
|
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every zone the JVM knows, resolved at [at]. [regionOf] (see
|
||||||
|
* [resolveAbbreviation]) supplies the zone's region for the abbreviation.
|
||||||
|
*
|
||||||
|
* This is ~600 entries, each costing a localized display name plus up to two ICU
|
||||||
|
* short-name lookups, so it is **not** cheap enough for the main thread — build
|
||||||
|
* it off-thread once and filter the result with [filterTimeZones] rather than
|
||||||
|
* rebuilding per keystroke.
|
||||||
|
*
|
||||||
|
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
||||||
|
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
||||||
|
* double up the real zones in the list, and none of them is what a user means
|
||||||
|
* when they pick a place.
|
||||||
|
*/
|
||||||
|
fun timeZoneOptions(
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
at: Instant = Instant.now(),
|
||||||
|
regionOf: (String) -> String? = { null },
|
||||||
|
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
||||||
|
.map { optionFor(ZoneId.of(it), locale, at, regionOf) }
|
||||||
|
.sortedWith(compareBy({ it.region }, { it.city }))
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz
|
||||||
|
* database doesn't know it — for labelling one known zone without paying to
|
||||||
|
* build the whole catalogue.
|
||||||
|
*/
|
||||||
|
fun timeZoneOptionOf(
|
||||||
|
id: String,
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
at: Instant = Instant.now(),
|
||||||
|
regionOf: (String) -> String? = { null },
|
||||||
|
): TimeZoneOption? {
|
||||||
|
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||||
|
return optionFor(zone, locale, at, regionOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads
|
||||||
|
* fine alone; any other offset-shaped name is normalised to our own GMT form so
|
||||||
|
* the offset isn't stated twice in ICU's spelling and ours.
|
||||||
|
*/
|
||||||
|
fun zoneDescriptor(option: TimeZoneOption): String {
|
||||||
|
val abbrev = option.shortName
|
||||||
|
return when {
|
||||||
|
abbrev == "UTC" -> "UTC"
|
||||||
|
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
|
||||||
|
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [options] matching [query], best matches first; a blank query returns the list
|
||||||
|
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
||||||
|
* spaces, so "sao paulo" finds "America/Sao_Paulo".
|
||||||
|
*
|
||||||
|
* Ranking puts a city that *starts with* the query above one that merely
|
||||||
|
* contains it — typing "col" should reach Colombo before Turks_and_Caicos —
|
||||||
|
* and the id is matched ahead of the localized name so a user who knows the
|
||||||
|
* IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a
|
||||||
|
* city prefix, so typing an abbreviation surfaces every zone that shows it
|
||||||
|
* (all the CEST zones together); the abbreviation is the one resolved for the
|
||||||
|
* display region, i.e. what the row actually shows.
|
||||||
|
*/
|
||||||
|
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
|
||||||
|
val needle = query.normalizeForSearch()
|
||||||
|
if (needle.isEmpty()) return options
|
||||||
|
return options
|
||||||
|
.mapNotNull { option ->
|
||||||
|
val keys = option.searchKeys
|
||||||
|
val rank = when {
|
||||||
|
keys.city.startsWith(needle) -> 0
|
||||||
|
keys.shortName == needle -> 1
|
||||||
|
keys.displayName.startsWith(needle) -> 2
|
||||||
|
keys.shortName.startsWith(needle) -> 3
|
||||||
|
keys.city.contains(needle) -> 4
|
||||||
|
keys.id.contains(needle) -> 5
|
||||||
|
keys.displayName.contains(needle) -> 6
|
||||||
|
else -> return@mapNotNull null
|
||||||
|
}
|
||||||
|
rank to option
|
||||||
|
}
|
||||||
|
.sortedWith(compareBy({ it.first }, { it.second.city }))
|
||||||
|
.map { it.second }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unicode combining marks — what NFD decomposition leaves an accent as. */
|
||||||
|
private val COMBINING_MARKS = Regex("\\p{Mn}+")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
|
||||||
|
* a query types the way a place is spoken rather than the way the tz database
|
||||||
|
* spells it.
|
||||||
|
*/
|
||||||
|
private fun String.normalizeForSearch(): String =
|
||||||
|
Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||||
|
.replace(COMBINING_MARKS, "")
|
||||||
|
.replace('_', ' ')
|
||||||
|
.replace('/', ' ')
|
||||||
|
.lowercase(Locale.ROOT)
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */
|
||||||
|
fun formatGmtOffset(offsetMinutes: Int): String {
|
||||||
|
if (offsetMinutes == 0) return "GMT"
|
||||||
|
val sign = if (offsetMinutes < 0) '-' else '+'
|
||||||
|
val abs = kotlin.math.abs(offsetMinutes)
|
||||||
|
return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60)
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
@@ -33,6 +33,7 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
|||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
@@ -72,6 +73,10 @@ fun CalendarHost(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
requestedInsertForm: EventForm? = null,
|
||||||
|
onInsertConsumed: () -> Unit = {},
|
||||||
|
requestedEditKey: LongArray? = null,
|
||||||
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// Wait for the persisted default view before seeding the stack, so the app
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
@@ -83,6 +88,8 @@ fun CalendarHost(
|
|||||||
// sensible non-empty initial values, so they're ready before the first frame.
|
// sensible non-empty initial values, so they're ready before the first frame.
|
||||||
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||||
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||||
|
// Whether the jump-to-today control sits in each view's top bar or the FAB (#60).
|
||||||
|
val todayInToolbar = viewModel.todayButtonInToolbar.collectAsStateWithLifecycle().value
|
||||||
|
|
||||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
@@ -170,12 +177,32 @@ fun CalendarHost(
|
|||||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||||
|
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||||
|
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||||
|
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
||||||
|
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||||
|
// flow — picker + summary — even for a single-event file, because the intent
|
||||||
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
|
// keeps routing a single event straight into the prefilled create form.
|
||||||
|
var importForceMany by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(requestedImportUri) {
|
LaunchedEffect(requestedImportUri) {
|
||||||
if (requestedImportUri != null) {
|
if (requestedImportUri != null) {
|
||||||
importUri = requestedImportUri
|
importUri = requestedImportUri
|
||||||
|
importForceMany = false
|
||||||
onImportConsumed()
|
onImportConsumed()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An external ACTION_INSERT launch (another app/widget creating an event,
|
||||||
|
// issue #30) arrives already prefilled — open it in the same create form the
|
||||||
|
// single-event .ics path uses. [importForm] is the topmost overlay, so it
|
||||||
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
|
LaunchedEffect(requestedInsertForm) {
|
||||||
|
if (requestedInsertForm != null) {
|
||||||
|
importFormSource = ImportSource.Insert
|
||||||
|
importForm = requestedInsertForm
|
||||||
|
onInsertConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close every overlay that can sit over the calendar, so an externally
|
// Close every overlay that can sit over the calendar, so an externally
|
||||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||||
@@ -189,6 +216,20 @@ fun CalendarHost(
|
|||||||
importForm = null
|
importForm = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||||
|
// widget) opens the occurrence straight in the edit form. Drop any covering
|
||||||
|
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
||||||
|
// without this it would open hidden underneath them. Same held-key pattern as
|
||||||
|
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
||||||
|
LaunchedEffect(requestedEditKey) {
|
||||||
|
if (requestedEditKey != null) {
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
heldEditKey = requestedEditKey
|
||||||
|
editKey = requestedEditKey
|
||||||
|
onEditKeyConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||||
@@ -262,7 +303,7 @@ fun CalendarHost(
|
|||||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
val viewSwitch = calendarFadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
@@ -272,12 +313,14 @@ fun CalendarHost(
|
|||||||
CalendarView.Week -> WeekScreen(
|
CalendarView.Week -> WeekScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -289,26 +332,31 @@ fun CalendarHost(
|
|||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onOpenSearch = onOpenSearch,
|
|
||||||
onCreateEvent = onCreateEvent,
|
|
||||||
quickSwitchViews = quickSwitchViews,
|
|
||||||
drawerViewOrder = drawerViewOrder,
|
|
||||||
)
|
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
|
||||||
selectedView = currentView,
|
|
||||||
onSelectView = onSelectView,
|
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
|
)
|
||||||
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
|
selectedView = currentView,
|
||||||
|
onSelectView = onSelectView,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenSearch = onOpenSearch,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,6 +391,14 @@ fun CalendarHost(
|
|||||||
heldEditKey = key
|
heldEditKey = key
|
||||||
editKey = key
|
editKey = key
|
||||||
},
|
},
|
||||||
|
onDuplicate = { form ->
|
||||||
|
// Reuse the prefilled-create overlay: a duplicate is a
|
||||||
|
// fresh event, so it applies the default reminder like an
|
||||||
|
// in-app new event (#52).
|
||||||
|
importFormSource = ImportSource.Insert
|
||||||
|
importForm = form
|
||||||
|
detailKey = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,7 +456,10 @@ fun CalendarHost(
|
|||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
) {
|
) {
|
||||||
CalendarsScreen(onBack = { showCalendars = false })
|
CalendarsScreen(
|
||||||
|
onBack = { showCalendars = false },
|
||||||
|
onImport = { importUri = it; importForceMany = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import flow for an opened/received .ics file. A single event routes
|
// Import flow for an opened/received .ics file. A single event routes
|
||||||
@@ -408,9 +467,11 @@ fun CalendarHost(
|
|||||||
importUri?.let { uri ->
|
importUri?.let { uri ->
|
||||||
ImportScreen(
|
ImportScreen(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
|
importFormSource = ImportSource.File
|
||||||
importForm = form
|
importForm = form
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -419,6 +480,7 @@ fun CalendarHost(
|
|||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = null,
|
initialDateIso = null,
|
||||||
initialForm = form,
|
initialForm = form,
|
||||||
|
initialFormSource = importFormSource,
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = IMPLEMENTED_VIEWS,
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
|
||||||
|
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ fun RootScreen(
|
|||||||
onWidgetNavConsumed: () -> Unit = {},
|
onWidgetNavConsumed: () -> Unit = {},
|
||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
|
onInsertConsumed: () -> Unit = {},
|
||||||
|
requestedEditKey: LongArray? = null,
|
||||||
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -84,6 +88,10 @@ fun RootScreen(
|
|||||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = onInsertConsumed,
|
||||||
|
requestedEditKey = requestedEditKey,
|
||||||
|
onEditKeyConsumed = onEditKeyConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The concrete span the range covers, starting at [start] through [end]
|
* The concrete span the range covers, starting at [start] through [end]
|
||||||
* (inclusive), for a human-readable header:
|
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
|
||||||
|
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
|
||||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||||
|
* year shown too only when it differs from the end's.
|
||||||
*/
|
*/
|
||||||
fun agendaRangeWindowSummary(
|
fun agendaRangeWindowSummary(
|
||||||
range: AgendaRange,
|
range: AgendaRange,
|
||||||
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
|
|||||||
): String {
|
): String {
|
||||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||||
|
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||||
return when (range) {
|
return when (range) {
|
||||||
AgendaRange.Day -> medium.format(javaStart)
|
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
else -> {
|
||||||
|
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||||
|
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
||||||
|
// split style can list a day with exactly the same visual language instead of
|
||||||
|
// growing a parallel set of event rows.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaDayHeader(
|
||||||
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpenDay(date) },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = agendaDayLabel(date, today),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = if (date == today) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card standing in for a day with no events — the same coffee-cup motif as the
|
||||||
|
* agenda's full-screen empty state, boxed into a card so the day keeps a visible slot
|
||||||
|
* rather than a bare header. Used for an anchored, event-less today (#35) and for
|
||||||
|
* an empty selected day in the month view's split style.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
// Match a single event row's resting corner radius (floret groupedShape).
|
||||||
|
shape = RoundedCornerShape(22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Coffee,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaEventRow(
|
||||||
|
event: EventInstance,
|
||||||
|
day: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
position: Position,
|
||||||
|
dimmed: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
GroupedRow(
|
||||||
|
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
|
title = title,
|
||||||
|
summary = agendaTimeSummary(event, day, zone),
|
||||||
|
position = position,
|
||||||
|
minHeight = 64.dp,
|
||||||
|
leading = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 6.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(eventFill(event.color, dark, soften)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||||
|
@Composable
|
||||||
|
internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||||
|
val relative = when (date) {
|
||||||
|
today -> stringResource(R.string.agenda_header_today)
|
||||||
|
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val formatted = formatAgendaDate(date)
|
||||||
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||||
|
*
|
||||||
|
* A multi-day event shows only the part relevant to [day], spelled out so each
|
||||||
|
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
||||||
|
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
||||||
|
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
||||||
|
val is24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
|
||||||
|
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||||
|
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||||
|
is AgendaTimeLabel.Starts -> stringResource(
|
||||||
|
R.string.agenda_span_starts,
|
||||||
|
formatTime(label.start, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
is AgendaTimeLabel.Ends -> stringResource(
|
||||||
|
R.string.agenda_span_ends,
|
||||||
|
formatTime(label.end, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
||||||
|
formatTime(label.end, zone, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
|
return if (location != null) "$time · $location" else time
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(
|
||||||
|
instant: Instant,
|
||||||
|
zone: TimeZone,
|
||||||
|
is24Hour: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
val t = instant.toLocalDateTime(zone).time
|
||||||
|
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
|
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||||
|
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||||
|
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.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
|
||||||
@@ -15,23 +12,21 @@ 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.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.DateRange
|
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -45,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
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
|
||||||
@@ -59,52 +52,47 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
// No file-level zone constant here on purpose: it would be fixed for the process
|
||||||
|
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
||||||
|
// time-zone change. The zone travels on AgendaUiState.Success instead.
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AgendaScreen(
|
fun AgendaScreen(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -147,12 +135,14 @@ fun AgendaScreen(
|
|||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = viewModel::goToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnToday,
|
todayVisible = !isOnToday && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.agenda_today_action),
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
onToday = viewModel::goToToday,
|
onToday = viewModel::goToToday,
|
||||||
onCreate = { onCreateEvent(anchor, null) },
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
@@ -169,9 +159,11 @@ fun AgendaScreen(
|
|||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
// end aligns the selector's right edge with the top-bar view
|
||||||
|
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
AgendaRangeBanner(
|
AgendaRangeBanner(
|
||||||
range = s.range,
|
range = s.range,
|
||||||
@@ -189,8 +181,10 @@ fun AgendaScreen(
|
|||||||
AgendaContent(
|
AgendaContent(
|
||||||
state = state,
|
state = state,
|
||||||
pastDisplay = pastDisplay,
|
pastDisplay = pastDisplay,
|
||||||
|
showToday = showToday,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
@@ -211,9 +205,11 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
* The agenda's current range, tapped to open the range picker as a session-only
|
||||||
* range picker as a session-only override. Filled with the primary container
|
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||||
* while an override is active, so the temporary state is obvious.
|
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
|
||||||
|
* switcher's secondary container, so it doesn't compete. Fills with the primary
|
||||||
|
* container only while an override is active, to make that temporary state clear.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaRangePill(
|
private fun AgendaRangePill(
|
||||||
@@ -222,43 +218,34 @@ private fun AgendaRangePill(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val container = if (isOverride) {
|
FilledTonalButton(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
|
containerColor = if (isOverride) {
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
}
|
},
|
||||||
val content = if (isOverride) {
|
contentColor = if (isOverride) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
},
|
||||||
Surface(
|
),
|
||||||
color = container,
|
modifier = modifier,
|
||||||
contentColor = content,
|
|
||||||
shape = RoundedCornerShape(50),
|
|
||||||
modifier = modifier.clickable(onClick = onClick),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.DateRange,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = agendaRangeLabel(range),
|
text = agendaRangeLabel(range),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
* A header naming the concrete window currently shown under a "showing …" label,
|
||||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||||
|
* lives on the selector button beside it, so it isn't repeated here.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaRangeBanner(
|
private fun AgendaRangeBanner(
|
||||||
@@ -275,8 +262,10 @@ private fun AgendaRangeBanner(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
// Just the concrete dates — the range's name ("Next 30 days") already
|
||||||
|
// sits on the selector button to the right, so repeating it here is noise.
|
||||||
Text(
|
Text(
|
||||||
text = "${agendaRangeLabel(range)}, $window",
|
text = window,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
@@ -287,8 +276,10 @@ private fun AgendaRangeBanner(
|
|||||||
private fun AgendaContent(
|
private fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
pastDisplay: PastEventDisplay,
|
pastDisplay: PastEventDisplay,
|
||||||
|
showToday: Boolean,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
@@ -301,7 +292,7 @@ private fun AgendaContent(
|
|||||||
// Hiding drops finished events — and any day they leave empty; dimming
|
// Hiding drops finished events — and any day they leave empty; dimming
|
||||||
// keeps them but fades the row. Recomputed each minute so events fall
|
// keeps them but fades the row. Recomputed each minute so events fall
|
||||||
// away (or fade) as they end while the screen stays open.
|
// away (or fade) as they end while the screen stays open.
|
||||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
state.days.mapNotNull { day ->
|
state.days.mapNotNull { day ->
|
||||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
@@ -309,15 +300,25 @@ private fun AgendaContent(
|
|||||||
} else {
|
} else {
|
||||||
state.days
|
state.days
|
||||||
}
|
}
|
||||||
|
// Anchor today with a "nothing left today" placeholder — but only when
|
||||||
|
// the window actually starts on today; a jumped-to date has no today in
|
||||||
|
// it, so anchoring there would be misleading (#35).
|
||||||
|
val days = anchorTodayIfMissing(
|
||||||
|
days = filtered,
|
||||||
|
today = state.today,
|
||||||
|
enabled = showToday && state.anchor == state.today,
|
||||||
|
)
|
||||||
if (days.isEmpty()) {
|
if (days.isEmpty()) {
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(
|
AgendaList(
|
||||||
days = days,
|
days = days,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
zone = state.zone,
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -330,9 +331,11 @@ private fun AgendaContent(
|
|||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -342,72 +345,40 @@ private fun AgendaList(
|
|||||||
) {
|
) {
|
||||||
days.forEach { day ->
|
days.forEach { day ->
|
||||||
stickyHeader(key = "header-${day.date}") {
|
stickyHeader(key = "header-${day.date}") {
|
||||||
AgendaDayHeader(date = day.date, today = today)
|
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||||
}
|
}
|
||||||
|
if (day.events.isEmpty()) {
|
||||||
|
// An anchored, event-less today (#35) — "nothing left today".
|
||||||
|
item(key = "placeholder-${day.date}") {
|
||||||
|
AgendaEmptyDayRow(
|
||||||
|
text = stringResource(R.string.agenda_no_more_today),
|
||||||
|
onClick = { onOpenDay(day.date) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = day.events,
|
items = day.events,
|
||||||
key = { _, event -> event.instanceId },
|
// Scope the key by day: a multi-day event appears under every
|
||||||
|
// day it spans, so its instanceId alone is not unique across
|
||||||
|
// the list (LazyColumn requires unique keys).
|
||||||
|
key = { _, event -> "${day.date}-${event.instanceId}" },
|
||||||
) { index, event ->
|
) { index, event ->
|
||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
|
day = day.date,
|
||||||
|
zone = zone,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
modifier = calendarAnimateItem(),
|
modifier = animateItemMotion(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = agendaDayLabel(date, today),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = if (date == today) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AgendaEventRow(
|
|
||||||
event: EventInstance,
|
|
||||||
position: Position,
|
|
||||||
dimmed: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
|
||||||
GroupedRow(
|
|
||||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
|
||||||
title = title,
|
|
||||||
summary = agendaTimeSummary(event),
|
|
||||||
position = position,
|
|
||||||
minHeight = 64.dp,
|
|
||||||
leading = {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(width = 6.dp, height = 36.dp)
|
|
||||||
.clip(RoundedCornerShape(3.dp))
|
|
||||||
.background(pastelize(event.color, dark)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
@@ -437,6 +408,8 @@ private fun AgendaTopBar(
|
|||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -455,6 +428,7 @@ private fun AgendaTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -474,42 +448,3 @@ private fun AgendaTopBar(
|
|||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
|
||||||
@Composable
|
|
||||||
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
|
||||||
val relative = when (date) {
|
|
||||||
today -> stringResource(R.string.agenda_header_today)
|
|
||||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val formatted = formatAgendaDate(date)
|
|
||||||
return if (relative != null) "$relative · $formatted" else formatted
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
|
||||||
@Composable
|
|
||||||
private fun agendaTimeSummary(event: EventInstance): String {
|
|
||||||
val time = if (event.isAllDay) {
|
|
||||||
stringResource(R.string.event_detail_all_day)
|
|
||||||
} else {
|
|
||||||
val is24Hour = LocalUse24HourFormat.current
|
|
||||||
val locale = currentLocale()
|
|
||||||
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
|
||||||
}
|
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
|
||||||
return if (location != null) "$time · $location" else time
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
|
|
||||||
val t = instant.toLocalDateTime(zone).time
|
|
||||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatAgendaDate(date: LocalDate): String {
|
|
||||||
val locale = Locale.getDefault()
|
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
|
||||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,9 +2,70 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone the event's calendar dates live in. Timed events are resolved in the
|
||||||
|
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
||||||
|
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
|
||||||
|
* a one-day event onto its next day. Matches the Week view and detail card.
|
||||||
|
*/
|
||||||
|
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||||
|
if (isAllDay) TimeZone.UTC else zone
|
||||||
|
|
||||||
|
/** The first calendar day this event occupies. */
|
||||||
|
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||||
|
start.toLocalDateTime(dateZone(zone)).date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last calendar day this event actually occupies. An event ending exactly at
|
||||||
|
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
||||||
|
* into that boundary day, so resolve the instant just before [end].
|
||||||
|
*/
|
||||||
|
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||||
|
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||||
|
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||||
|
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||||
|
spanFirstDay(zone) != spanLastDay(zone)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an agenda row's time line should convey for an event on a given day —
|
||||||
|
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||||
|
* agenda screen and the agenda widget label multi-day events identically; each
|
||||||
|
* surface only formats the instants into its own locale/24h string.
|
||||||
|
*/
|
||||||
|
sealed interface AgendaTimeLabel {
|
||||||
|
/** An all-day event, or a whole in-between day of a multi-day span. */
|
||||||
|
data object AllDay : AgendaTimeLabel
|
||||||
|
/** The first day of a multi-day timed event: when it begins. */
|
||||||
|
data class Starts(val start: Instant) : AgendaTimeLabel
|
||||||
|
/** The last day of a multi-day timed event: when it ends. */
|
||||||
|
data class Ends(val end: Instant) : AgendaTimeLabel
|
||||||
|
/** A single-day timed event: its start–end range. */
|
||||||
|
data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
|
||||||
|
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
|
||||||
|
if (event.isAllDay) return AgendaTimeLabel.AllDay
|
||||||
|
val firstDay = event.spanFirstDay(zone)
|
||||||
|
val lastDay = event.spanLastDay(zone)
|
||||||
|
return when {
|
||||||
|
firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end)
|
||||||
|
day <= firstDay -> AgendaTimeLabel.Starts(event.start)
|
||||||
|
day >= lastDay -> AgendaTimeLabel.Ends(event.end)
|
||||||
|
else -> AgendaTimeLabel.AllDay // a full in-between day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One calendar day with at least one event, for the agenda list. */
|
/** One calendar day with at least one event, for the agenda list. */
|
||||||
data class AgendaDay(
|
data class AgendaDay(
|
||||||
@@ -15,22 +76,41 @@ data class AgendaDay(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||||
* actually carry events). An event that began before [anchor] (ongoing or
|
* actually carry events). A multi-day event surfaces on *every* day it spans,
|
||||||
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
|
||||||
* day, all-day events sort first, then ascending by start time, then title.
|
* before the window (ongoing) still lists from the anchor day, and one running
|
||||||
|
* past the window stops at the last visible day. Within a day, all-day events
|
||||||
|
* sort first, then ascending by start time, then title.
|
||||||
*
|
*
|
||||||
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||||
* and order identically.
|
* and order identically.
|
||||||
*/
|
*/
|
||||||
fun groupAgendaDays(
|
fun groupAgendaDays(
|
||||||
anchor: LocalDate,
|
anchor: LocalDate,
|
||||||
|
windowEnd: LocalDate,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): List<AgendaDay> =
|
): List<AgendaDay> {
|
||||||
instances
|
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||||
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
for (instance in instances) {
|
||||||
.toSortedMap()
|
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||||
.map { (date, dayEvents) ->
|
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
|
||||||
|
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
|
||||||
|
// The provider returns an event whenever its instant span overlaps the query
|
||||||
|
// window, but all-day events live at UTC midnights with an exclusive end: east
|
||||||
|
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
|
||||||
|
// overlaps today's window start and comes back even though its true last day
|
||||||
|
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
|
||||||
|
// surface it under "today" (issue #65); drop it instead. The symmetric case —
|
||||||
|
// a next-day all-day event overlapping the window's last instant — drops too.
|
||||||
|
if (lastDay < firstDay) continue
|
||||||
|
var day = firstDay
|
||||||
|
while (day <= lastDay) {
|
||||||
|
byDay.getOrPut(day) { mutableListOf() }.add(instance)
|
||||||
|
day = day.plus(1, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byDay.map { (date, dayEvents) ->
|
||||||
AgendaDay(
|
AgendaDay(
|
||||||
date = date,
|
date = date,
|
||||||
events = dayEvents.sortedWith(
|
events = dayEvents.sortedWith(
|
||||||
@@ -40,6 +120,25 @@ fun groupAgendaDays(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||||
|
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
||||||
|
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
|
||||||
|
* when today already has its own day in [days]. Keeps today anchored at the top
|
||||||
|
* so a glance tells today's events apart from a future day's (issue #35).
|
||||||
|
*/
|
||||||
|
fun anchorTodayIfMissing(
|
||||||
|
days: List<AgendaDay>,
|
||||||
|
today: LocalDate,
|
||||||
|
enabled: Boolean,
|
||||||
|
): List<AgendaDay> =
|
||||||
|
if (enabled && days.none { it.date == today }) {
|
||||||
|
listOf(AgendaDay(today, emptyList())) + days
|
||||||
|
} else {
|
||||||
|
days
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||||
@@ -61,5 +160,12 @@ sealed interface AgendaUiState {
|
|||||||
val rangeEnd: LocalDate,
|
val rangeEnd: LocalDate,
|
||||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||||
val showRangeBar: Boolean,
|
val showRangeBar: Boolean,
|
||||||
|
/**
|
||||||
|
* The zone [days] were grouped in. Carried in the state rather than
|
||||||
|
* re-read by the screen so labelling and grouping cannot disagree: an
|
||||||
|
* event's "Starts …/Ends …/All day" line is only correct relative to the
|
||||||
|
* same zone that decided which day it was filed under.
|
||||||
|
*/
|
||||||
|
val zone: TimeZone,
|
||||||
) : AgendaUiState
|
) : AgendaUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.util.Locale
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
@@ -51,8 +49,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||||
|
|
||||||
// First day of the week, for the calendar-aligned "this week" range.
|
// First day of the week, for the calendar-aligned "this week" range.
|
||||||
private val weekStartDay = settingsPrefs.weekStart
|
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How to treat events that already ended today (show / dim / hide). A display
|
* How to treat events that already ended today (show / dim / hide). A display
|
||||||
@@ -66,6 +63,19 @@ class AgendaViewModel @Inject constructor(
|
|||||||
initialValue = PastEventDisplay.SHOW,
|
initialValue = PastEventDisplay.SHOW,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to keep today anchored at the top with a "nothing left today"
|
||||||
|
* placeholder even once it has no remaining events (#35). A display concern
|
||||||
|
* applied in the composition (after past-event filtering), so it rides
|
||||||
|
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||||
|
*/
|
||||||
|
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = true,
|
||||||
|
)
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
@@ -146,11 +156,11 @@ class AgendaViewModel @Inject constructor(
|
|||||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
val anchor = params.anchor
|
val anchor = params.anchor
|
||||||
val days = groupAgendaDays(anchor, instances, zone)
|
|
||||||
val rangeEnd = anchor.plus(
|
val rangeEnd = anchor.plus(
|
||||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
|
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||||
return AgendaUiState.Success(
|
return AgendaUiState.Success(
|
||||||
anchor = anchor,
|
anchor = anchor,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
@@ -159,6 +169,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
rangeIsOverride = params.rangeIsOverride,
|
rangeIsOverride = params.rangeIsOverride,
|
||||||
rangeEnd = rangeEnd,
|
rangeEnd = rangeEnd,
|
||||||
showRangeBar = params.showRangeBar,
|
showRangeBar = params.showRangeBar,
|
||||||
|
zone = zone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import android.text.format.DateUtils
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -37,6 +35,7 @@ import androidx.compose.material.icons.filled.Close
|
|||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.FileDownload
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
@@ -45,6 +44,7 @@ import androidx.compose.material.icons.filled.Visibility
|
|||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -66,6 +66,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -73,10 +74,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringArrayResource
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
@@ -85,7 +83,6 @@ import androidx.compose.ui.semantics.contentDescription
|
|||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -96,22 +93,38 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||||
|
|
||||||
|
// SAF mime filter for the restore picker. `.ics` files reach us under several
|
||||||
|
// mimes depending on the source app (our own export uses text/calendar; others
|
||||||
|
// hand them out as octet-stream or text/plain), so accept the common set rather
|
||||||
|
// than hide valid backups behind an over-tight filter.
|
||||||
|
private val RESTORE_MIME_TYPES = arrayOf(
|
||||||
|
"text/calendar",
|
||||||
|
"application/octet-stream",
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||||
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||||
@@ -122,6 +135,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
|||||||
@Composable
|
@Composable
|
||||||
fun CalendarsScreen(
|
fun CalendarsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onImport: (android.net.Uri) -> Unit,
|
||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
@@ -168,6 +182,7 @@ fun CalendarsScreen(
|
|||||||
onConsumeError = viewModel::consumeError,
|
onConsumeError = viewModel::consumeError,
|
||||||
backupResult = backupResult,
|
backupResult = backupResult,
|
||||||
onExportBackup = viewModel::exportBackup,
|
onExportBackup = viewModel::exportBackup,
|
||||||
|
onImport = onImport,
|
||||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||||
autoBackup = autoBackup,
|
autoBackup = autoBackup,
|
||||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||||
@@ -190,7 +205,8 @@ private fun CalendarsList(
|
|||||||
error: Boolean,
|
error: Boolean,
|
||||||
onConsumeError: () -> Unit,
|
onConsumeError: () -> Unit,
|
||||||
backupResult: BackupResult?,
|
backupResult: BackupResult?,
|
||||||
onExportBackup: (android.net.Uri) -> Unit,
|
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
|
||||||
|
onImport: (android.net.Uri) -> Unit,
|
||||||
onConsumeBackupResult: () -> Unit,
|
onConsumeBackupResult: () -> Unit,
|
||||||
autoBackup: AutoBackupUiState,
|
autoBackup: AutoBackupUiState,
|
||||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||||
@@ -218,10 +234,19 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||||
// to the VM to stream the .ics into.
|
// to the VM to stream the .ics into. This launcher exports everything
|
||||||
|
// eligible (null); the per-calendar selector owns its own launcher.
|
||||||
val createBackup = rememberLauncherForActivityResult(
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
) { uri -> uri?.let(onExportBackup) }
|
) { uri -> uri?.let { onExportBackup(it, null) } }
|
||||||
|
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// SAF "open document" picker for restoring events from a .ics file. The
|
||||||
|
// picked Uri is handed up to the host, which runs it through the same import
|
||||||
|
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
||||||
|
val openBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
|
) { uri -> uri?.let(onImport) }
|
||||||
|
|
||||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||||
// write grant so background runs can keep writing to it.
|
// write grant so background runs can keep writing to it.
|
||||||
@@ -253,6 +278,7 @@ private fun CalendarsList(
|
|||||||
title = stringResource(R.string.calendars_title),
|
title = stringResource(R.string.calendars_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
// What the per-calendar / per-account switches below actually do.
|
// What the per-calendar / per-account switches below actually do.
|
||||||
HintText(stringResource(R.string.calendars_disable_hint))
|
HintText(stringResource(R.string.calendars_disable_hint))
|
||||||
@@ -301,8 +327,13 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Backup — local calendars have no sync, so a .ics export is their only
|
// Backup — local calendars have no sync, so a .ics export is their only
|
||||||
// safety net. Offered only when there is something to back up.
|
// safety net. Offered only when there is something exportable: the user's
|
||||||
if (local.isNotEmpty()) {
|
// own local calendars (managed special-dates mirrors don't count).
|
||||||
|
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
||||||
|
// Restore/import can target any writable, non-managed calendar (local or
|
||||||
|
// synced), so its availability is broader than export's.
|
||||||
|
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
|
||||||
|
if (exportable.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
@@ -313,7 +344,22 @@ private fun CalendarsList(
|
|||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
// With more than one exportable calendar, let the user choose
|
||||||
|
// which to include; a single one exports straight away.
|
||||||
|
if (exportable.size == 1) {
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
} else {
|
||||||
|
showExportPicker = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
summary = stringResource(R.string.calendars_restore_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = {
|
||||||
|
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -342,6 +388,19 @@ private fun CalendarsList(
|
|||||||
)
|
)
|
||||||
HintText(backupStatusText(autoBackup.status))
|
HintText(backupStatusText(autoBackup.status))
|
||||||
}
|
}
|
||||||
|
} else if (canImport) {
|
||||||
|
// Nothing to back up (no writable local calendar), but events can
|
||||||
|
// still be restored into a writable calendar — offer restore on its
|
||||||
|
// own so it isn't hidden behind export eligibility.
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||||
|
HintText(stringResource(R.string.calendars_restore_hint))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_restore_action),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||||
|
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
@@ -408,6 +467,86 @@ private fun CalendarsList(
|
|||||||
onDismiss = { showInterval = false },
|
onDismiss = { showInterval = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showExportPicker) {
|
||||||
|
ExportCalendarPicker(
|
||||||
|
calendars = local.filter { it.canModifyContents && !it.isManaged },
|
||||||
|
onExport = onExportBackup,
|
||||||
|
onDismiss = { showExportPicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||||
|
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||||
|
* the picked file with the chosen calendar ids.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ExportCalendarPicker(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
onExport: (android.net.Uri, Set<Long>?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Seed once with everything selected and hold it across recomposition and
|
||||||
|
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
|
||||||
|
// it would silently reset the user's de-selections whenever the provider
|
||||||
|
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
|
||||||
|
// — the data layer intersects the chosen set with the eligible calendars.
|
||||||
|
var selected by rememberSaveable(
|
||||||
|
stateSaver = listSaver(
|
||||||
|
save = { it.toList() },
|
||||||
|
restore = { it.toSet() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
mutableStateOf(calendars.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
val createBackup = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
onExport(uri, selected)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.calendars_export_title),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
) {
|
||||||
|
HintText(stringResource(R.string.calendars_export_hint))
|
||||||
|
calendars.forEachIndexed { index, calendar ->
|
||||||
|
val isSelected = calendar.id in selected
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = calendar.description,
|
||||||
|
position = positionOf(index, calendars.size),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
},
|
||||||
|
enabled = selected.isNotEmpty(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.calendars_export_action))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -427,6 +566,7 @@ private fun CalendarEditor(
|
|||||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -486,7 +626,7 @@ private fun CalendarEditor(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) {
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
@@ -664,8 +804,8 @@ private fun CalendarGroup(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = expanded,
|
visible = expanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column(content = body)
|
Column(content = body)
|
||||||
}
|
}
|
||||||
@@ -741,66 +881,6 @@ private fun CalendarGroupMenu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
|
|
||||||
* round 40dp chip, so each synced account is recognisable at a glance. We load
|
|
||||||
* whatever app owns the account from [PackageManager] rather than bundling brand
|
|
||||||
* logos — always accurate, nothing to license. Falls back to a neutral cloud
|
|
||||||
* chip when no installed app resolves for the account.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun SourceLogo(accountType: String) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
|
|
||||||
if (logo != null) {
|
|
||||||
Image(
|
|
||||||
bitmap = logo,
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
LeadingAvatar(Icons.Default.Cloud)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
|
||||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
|
||||||
val pm = context.packageManager
|
|
||||||
val candidates = buildList {
|
|
||||||
curatedSourcePackage(accountType)?.let { add(it) }
|
|
||||||
AccountManager.get(context).authenticatorTypes
|
|
||||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
|
||||||
?.packageName
|
|
||||||
?.let { add(it) }
|
|
||||||
}
|
|
||||||
for (pkg in candidates) {
|
|
||||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
|
||||||
if (bitmap != null) return bitmap.asImageBitmap()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
|
||||||
@Composable
|
|
||||||
private fun LeadingAvatar(icon: ImageVector) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
private fun SectionHeader(text: String) {
|
||||||
@@ -935,9 +1015,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent {
|
|||||||
return Intent(Settings.ACTION_SYNC_SETTINGS)
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -107,11 +107,11 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||||
* in [backupResult] for a one-shot message.
|
* in [backupResult] for a one-shot message.
|
||||||
*/
|
*/
|
||||||
fun exportBackup(uri: Uri) {
|
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_backupResult.value = try {
|
_backupResult.value = try {
|
||||||
val count = withContext(io) {
|
val count = withContext(io) {
|
||||||
val events = repository.exportEvents()
|
val events = repository.exportEvents(calendarIds)
|
||||||
icsExporter.writeDocument(
|
icsExporter.writeDocument(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||||
|
|||||||
@@ -13,31 +13,18 @@ import androidx.compose.runtime.Composable
|
|||||||
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
|
||||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
|
||||||
* - Keeps the hue (so users still recognise their calendars)
|
|
||||||
* - Caps saturation so harsh provider colors stop screaming
|
|
||||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
|
||||||
*/
|
|
||||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
|
||||||
val hsv = FloatArray(3)
|
|
||||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
|
||||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
|
||||||
hsv[2] = if (dark) 0.82f else 0.72f
|
|
||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
* 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
|
* in the calendar's colour — softened to a pastel, or raw when the softener is
|
||||||
* visibility filter so they read identically.
|
* off (issue #36). Shared by the calendar manager and the visibility filter so
|
||||||
|
* they read identically.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
@@ -48,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.CalendarMonth,
|
Icons.Filled.CalendarMonth,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = pastelize(color, dark),
|
tint = eventFill(color, dark, soften),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ 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
|
||||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's single "which calendar" selection list, shared by the event editor
|
||||||
|
* and the .ics import screen. Renders the same grouped-card system as the
|
||||||
|
* calendar-manager screen: a category header per source — the device chip for
|
||||||
|
* the app's own calendars, the owning app's launcher icon for each synced
|
||||||
|
* account — with the calendars beneath it as a connected card, a colour chip on
|
||||||
|
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||||
|
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ColumnScope.CalendarPickerGroups(
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
selectedId: Long?,
|
||||||
|
onSelect: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||||
|
val syncedGroups = remember(calendars) {
|
||||||
|
calendars.filterNot { it.isLocal }
|
||||||
|
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (local.isNotEmpty()) {
|
||||||
|
CalendarPickerGroup(
|
||||||
|
title = stringResource(R.string.calendars_local_header),
|
||||||
|
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||||
|
calendars = local,
|
||||||
|
selectedId = selectedId,
|
||||||
|
onSelect = onSelect,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
syncedGroups.forEachIndexed { index, (account, cals) ->
|
||||||
|
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||||
|
CalendarPickerGroup(
|
||||||
|
title = account,
|
||||||
|
leading = { SourceLogo(cals.first().accountType) },
|
||||||
|
calendars = cals,
|
||||||
|
selectedId = selectedId,
|
||||||
|
onSelect = onSelect,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||||
|
@Composable
|
||||||
|
private fun CalendarPickerGroup(
|
||||||
|
title: String,
|
||||||
|
leading: @Composable () -> Unit,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
selectedId: Long?,
|
||||||
|
onSelect: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = title,
|
||||||
|
position = Position.Top,
|
||||||
|
leading = leading,
|
||||||
|
)
|
||||||
|
calendars.forEachIndexed { index, calendar ->
|
||||||
|
val isSelected = calendar.id == selectedId
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||||
|
selected = isSelected,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { onSelect(calendar.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
|
||||||
|
* round 40dp chip, so each synced account is recognisable at a glance. We load
|
||||||
|
* whatever app owns the account from [android.content.pm.PackageManager] rather
|
||||||
|
* than bundling brand logos — always accurate, nothing to license. Falls back to
|
||||||
|
* a neutral cloud chip when no installed app resolves for the account.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SourceLogo(accountType: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
|
||||||
|
if (logo != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = logo,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LeadingAvatar(Icons.Default.Cloud)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||||
|
@Composable
|
||||||
|
fun LeadingAvatar(icon: ImageVector) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||||
|
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val candidates = buildList {
|
||||||
|
curatedSourcePackage(accountType)?.let { add(it) }
|
||||||
|
AccountManager.get(context).authenticatorTypes
|
||||||
|
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||||
|
?.packageName
|
||||||
|
?.let { add(it) }
|
||||||
|
}
|
||||||
|
for (pkg in candidates) {
|
||||||
|
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||||
|
if (bitmap != null) return bitmap.asImageBitmap()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||||
|
internal fun curatedSourcePackage(accountType: String): String? = when {
|
||||||
|
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag distance that turns a calendar page, shared by the month, week and day
|
||||||
|
* views so all three answer a swipe at the same point.
|
||||||
|
*
|
||||||
|
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
|
||||||
|
* changed the month, and a page that turns on an unintended gesture reads as the
|
||||||
|
* animation misfiring rather than as the gesture being over-eager.
|
||||||
|
*/
|
||||||
|
val CALENDAR_SWIPE_THRESHOLD = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole-page horizontal swipe: one page per gesture, committed **the moment
|
||||||
|
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
|
||||||
|
*
|
||||||
|
* Waiting for the lift meant the page sat still under a finger that had already
|
||||||
|
* travelled far enough to ask for it, and the answer only arrived once you let
|
||||||
|
* go — which reads as the view being slow rather than as a deliberate commit.
|
||||||
|
* Firing on the threshold is what makes the gesture feel like it is being
|
||||||
|
* followed. The trade is that a drag can no longer be taken back by dragging the
|
||||||
|
* other way; in practice, once you have moved 24dp deliberately you meant it, and
|
||||||
|
* the page you land on is one swipe back.
|
||||||
|
*
|
||||||
|
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
|
||||||
|
* underneath this, and a two-dimensional detector here would claim those drags
|
||||||
|
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
|
||||||
|
* detector's slop while a vertical one is consumed below, and the two coexist.
|
||||||
|
* (The month view's split style needs a vertical axis as well, so it keeps its
|
||||||
|
* own axis-locking detector rather than using this.)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberCalendarPageSwipe(
|
||||||
|
onSwipeNext: () -> Unit,
|
||||||
|
onSwipePrev: () -> Unit,
|
||||||
|
): Modifier {
|
||||||
|
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
|
||||||
|
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
|
||||||
|
var accum = 0f
|
||||||
|
// One page per gesture: without this a long drag would keep re-firing
|
||||||
|
// every time the accumulator crossed the threshold again.
|
||||||
|
var fired = false
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
accum = 0f
|
||||||
|
fired = false
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
accum = 0f
|
||||||
|
fired = false
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
accum = 0f
|
||||||
|
fired = false
|
||||||
|
},
|
||||||
|
onHorizontalDrag = { _, drag ->
|
||||||
|
accum += drag
|
||||||
|
if (!fired) {
|
||||||
|
val commit = when {
|
||||||
|
accum < -threshold -> onSwipeNext
|
||||||
|
accum > threshold -> onSwipePrev
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (commit != null) {
|
||||||
|
fired = true
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a calendar title (top bar or widget header): [skeleton]'s fields laid
|
||||||
|
* out in [locale]'s own order, with the year shown only when [date] falls outside
|
||||||
|
* [currentYear].
|
||||||
|
*
|
||||||
|
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
|
||||||
|
* day. A skeleton is a field list, so wanting the year is just asking for one
|
||||||
|
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||||
|
*
|
||||||
|
* Dropping the year in the current year is the one bit of policy here: the title
|
||||||
|
* sits directly above a grid that already says which year it is, and the year's
|
||||||
|
* *absence* is itself the signal that you're in the current one — it appears the
|
||||||
|
* moment you page out of it, which is when it starts carrying information.
|
||||||
|
*
|
||||||
|
* [forceYear] overrides that for titles whose [date] does not tell the whole
|
||||||
|
* story — a week view's title names only the month its *first* day falls in, so
|
||||||
|
* a week straddling New Year must still show the year even though [date] is in
|
||||||
|
* [currentYear].
|
||||||
|
*/
|
||||||
|
fun formatCalendarTitle(
|
||||||
|
date: java.time.LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
currentYear: Int,
|
||||||
|
skeleton: String,
|
||||||
|
forceYear: Boolean = false,
|
||||||
|
): String {
|
||||||
|
val fields = if (date.year == currentYear && !forceYear) skeleton else skeleton + "y"
|
||||||
|
return localizedDateFormatter(locale, fields).format(date)
|
||||||
|
}
|
||||||
@@ -1,61 +1,36 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.BackEventCompat
|
|
||||||
import androidx.activity.compose.PredictiveBackHandler
|
|
||||||
import androidx.compose.animation.ContentTransform
|
import androidx.compose.animation.ContentTransform
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.SizeTransform
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.lazy.LazyItemScope
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the user has asked the system to remove animations (Settings →
|
* Calendar-specific motion. The family's generic content transitions — section
|
||||||
* Accessibility → "Remove animations", which sets the global animator duration
|
* expand/collapse, item reveal, list relayout, cross-fade, predictive-back, and
|
||||||
* scale to 0). Compose animations do *not* honour this platform flag on their
|
* the reduce-motion check — now live in floret-kit's identity module
|
||||||
* own, so the shared motion helpers in this file check it and fall back to a
|
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||||
* quick cross-fade — opacity only, no spatial movement — to respect the
|
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||||
* vestibular intent of the setting while keeping state changes legible.
|
* here is only what's specific to paging the calendar grid: the directional
|
||||||
*
|
* month/week/day slide and the specs that feed it.
|
||||||
* Read once at composition; the scale changes rarely and only takes full effect
|
|
||||||
* after a process restart anyway.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
|
||||||
fun rememberReduceMotion(): Boolean {
|
|
||||||
val resolver = LocalContext.current.contentResolver
|
|
||||||
return remember(resolver) {
|
|
||||||
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
* The M3 Expressive spatial spring used for the month/week/day slide: the
|
||||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
* *default* spring-physics spec from the active motion scheme, rather than a
|
||||||
* springy settle, rather than a fixed easing curve.
|
* fixed easing curve.
|
||||||
|
*
|
||||||
|
* Default rather than fast: `fastSpatialSpec` is tuned for small, incidental
|
||||||
|
* movement, and driving a whole calendar page with it made the settle read as a
|
||||||
|
* twitch instead of a glide.
|
||||||
*
|
*
|
||||||
* Read it in a composable scope (this helper) so it can be captured by the
|
* Read it in a composable scope (this helper) so it can be captured by the
|
||||||
* non-composable `AnimatedContent` transitionSpec lambda.
|
* non-composable `AnimatedContent` transitionSpec lambda.
|
||||||
@@ -63,27 +38,34 @@ fun rememberReduceMotion(): Boolean {
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The fast effects spec from the active motion scheme, for opacity (fade)
|
* The effects spec from the active motion scheme, for the opacity half of the
|
||||||
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||||
* for use in non-composable transition lambdas and as the reduced-motion fallback.
|
* for use in non-composable transition lambdas, and reused on its own as the
|
||||||
|
* reduced-motion fallback.
|
||||||
|
*
|
||||||
|
* Opacity gets its own spec on purpose: M3 Expressive springs *position* and
|
||||||
|
* eases *opacity*, and a fade that bounced with the slide would shimmer.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal slide for navigating between adjacent months/weeks/days.
|
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
|
||||||
|
* outgoing page slides and fades one way while the incoming one arrives from the
|
||||||
|
* other, position on a spring and opacity on an easing curve.
|
||||||
*
|
*
|
||||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
* (e.g. "today"); a jump reuses the forward direction.
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||||
* @param fadeSpec effects spec for the reduced-motion fade, typically
|
* @param fadeSpec effects spec for the opacity half, and for the whole
|
||||||
|
* transition under reduced motion; typically
|
||||||
* [rememberCalendarFadeSpec].
|
* [rememberCalendarFadeSpec].
|
||||||
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
|
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
||||||
*/
|
*/
|
||||||
fun calendarSlideTransition(
|
fun calendarSlideTransition(
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
@@ -95,131 +77,26 @@ fun calendarSlideTransition(
|
|||||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
}
|
}
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return ContentTransform(
|
||||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
targetContentEnter =
|
||||||
}
|
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
|
||||||
|
initialContentExit =
|
||||||
/**
|
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
||||||
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
|
// AnimatedContent clips to the animating container by default, which
|
||||||
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
|
// shears the pages against the viewport edge as they pass. There is no
|
||||||
* opacity, so it doubles as its own reduced-motion form.
|
// size change here to contain — both pages are the same grid.
|
||||||
*/
|
sizeTransform = SizeTransform(clip = false),
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarFadeThrough(): ContentTransform {
|
|
||||||
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
|
||||||
return fadeIn(fade).togetherWith(fadeOut(fade))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter transition for a vertically-revealed section (expandable rows, inline
|
|
||||||
* fields): height grows from the top while fading in. Under reduced motion the
|
|
||||||
* height growth is dropped, leaving a quick fade.
|
|
||||||
*
|
|
||||||
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
|
|
||||||
* originally inlined in the event edit form, so every expandable surface in the
|
|
||||||
* app reveals the same way.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
|
||||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
|
||||||
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
|
|
||||||
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
|
|
||||||
* motion keeps the fade only.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
|
||||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
|
|
||||||
* app's motion scheme instead of Compose's default spring. Returns a bare
|
|
||||||
* [Modifier] under reduced motion so rows snap into place. Requires the list to
|
|
||||||
* supply stable item keys.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
|
||||||
if (reduceMotion) {
|
|
||||||
Modifier
|
|
||||||
} else {
|
|
||||||
Modifier.animateItem(
|
|
||||||
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
|
||||||
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
|
||||||
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard Android predictive-back transform for a full-screen overlay: as the
|
* How far a page travels, as a fraction of the container width.
|
||||||
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
|
||||||
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
|
||||||
* gesture invokes [onBack]; cancelling springs it back.
|
|
||||||
*
|
*
|
||||||
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
* A full width was the obvious reading of "paging", but the two pages are
|
||||||
* once and apply the returned [Modifier] to that screen's root so the preview
|
* stacked and both opaque, so a full-width slide showed one grid racing across
|
||||||
* respects the same back semantics. Under reduced motion the visual preview is
|
* another — the movement carried the whole transition and had a long way to go.
|
||||||
* skipped (the back still works); on API < 34 the system delivers no progress,
|
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
||||||
* so it degrades to a plain back.
|
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
||||||
|
* leaves nothing skating past.
|
||||||
*/
|
*/
|
||||||
@Composable
|
private const val SLIDE_TRAVEL_DIVISOR = 5
|
||||||
fun Modifier.predictiveBack(
|
|
||||||
onBack: () -> Unit,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
reduceMotion: Boolean = rememberReduceMotion(),
|
|
||||||
): Modifier {
|
|
||||||
val progress = remember { Animatable(0f) }
|
|
||||||
var fromLeftEdge by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
PredictiveBackHandler(enabled = enabled) { events ->
|
|
||||||
try {
|
|
||||||
events.collect { event ->
|
|
||||||
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
|
||||||
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
|
||||||
}
|
|
||||||
onBack()
|
|
||||||
progress.snapTo(0f)
|
|
||||||
} catch (_: CancellationException) {
|
|
||||||
progress.animateTo(0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reduceMotion) return this
|
|
||||||
return this.graphicsLayer {
|
|
||||||
val p = progress.value
|
|
||||||
val scale = 1f - 0.1f * p
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
|
|
||||||
shape = RoundedCornerShape(32.dp.toPx() * p)
|
|
||||||
clip = p > 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ import androidx.compose.runtime.Composable
|
|||||||
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapping row of round colour swatches; the one matching [selected] is
|
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||||
* ringed and checked. Shared by the calendar editor and the event-colour
|
* ringed and checked. Shared by the calendar editor and the event-colour
|
||||||
* picker so both pick a colour the same way. Swatches render through
|
* picker so both pick a colour the same way. Swatches render through
|
||||||
* [pastelize] — the softened colour the app actually paints, not the raw hue.
|
* [eventFill] — the colour the app actually paints (softened, or raw when the
|
||||||
|
* softener is off, issue #36), not necessarily the stored hue.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -36,16 +36,18 @@ fun ColorSwatchRow(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
colors.forEach { argb ->
|
colors.forEach { argb ->
|
||||||
val isSelected = argb == selected
|
val isSelected = argb == selected
|
||||||
|
val fill = eventFill(argb, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(pastelize(argb, dark))
|
.background(fill)
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
@@ -59,7 +61,7 @@ fun ColorSwatchRow(
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Check,
|
Icons.Default.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.Black.copy(alpha = 0.7f),
|
tint = eventInk(fill, alpha = 0.7f),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
|
||||||
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
|
||||||
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
|
||||||
* call site so it never reaches a release build. Non-interactive: it's a plain
|
|
||||||
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
|
||||||
*
|
|
||||||
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
|
||||||
* so it overlays the UI.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun BoxScope.DebugRibbon() {
|
|
||||||
Text(
|
|
||||||
text = "DEBUG",
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
letterSpacing = 1.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.zIndex(1f)
|
|
||||||
// Push the band out so its midline crosses the very corner, then
|
|
||||||
// rotate it to the classic 45° ribbon.
|
|
||||||
.offset(x = 36.dp, y = 24.dp)
|
|
||||||
.rotate(45f)
|
|
||||||
.background(Color(0xFFB23B00))
|
|
||||||
.width(140.dp)
|
|
||||||
.padding(vertical = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
|
|
||||||
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
|
|
||||||
* matches the card/grouped-row design language (not Material's outlined field).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun DialogAmountField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
placeholder: String,
|
|
||||||
) {
|
|
||||||
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
|
|
||||||
// so anything lower vanishes.
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
) {
|
|
||||||
InlineTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = { text ->
|
|
||||||
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
|
|
||||||
},
|
|
||||||
placeholder = placeholder,
|
|
||||||
textStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
modifier = Modifier
|
|
||||||
.width(72.dp)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
|
||||||
@Composable
|
|
||||||
fun DialogUnitDropdown(
|
|
||||||
label: String,
|
|
||||||
entries: List<String>,
|
|
||||||
onPick: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
var open by remember { mutableStateOf(false) }
|
|
||||||
Box {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
onClick = { open = true },
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
|
||||||
) {
|
|
||||||
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
|
||||||
entries.forEachIndexed { index, entry ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(entry) },
|
|
||||||
onClick = {
|
|
||||||
onPick(index)
|
|
||||||
open = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether calendar/event colours are softened toward theme-fitting pastels
|
||||||
|
* before display (issue #36). Provided app-wide from the "soften colours"
|
||||||
|
* setting; the default `true` keeps the historical look. When off, the raw
|
||||||
|
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
||||||
|
* and other calendar apps. Widgets live outside this composition and read the
|
||||||
|
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
||||||
|
*/
|
||||||
|
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
||||||
|
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
||||||
|
* pastelize also returns an opaque colour).
|
||||||
|
*/
|
||||||
|
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
|
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
||||||
|
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
||||||
|
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
||||||
|
* perceptually dark even after softening pins its HSV value, so black text on it
|
||||||
|
* reads poorly. The choice is objective, not a tuned threshold: white wins only
|
||||||
|
* when it out-contrasts black against the fill, which (by the WCAG contrast
|
||||||
|
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
|
||||||
|
* near-black text. [alpha] carries the caller's soft emphasis.
|
||||||
|
*/
|
||||||
|
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
|
||||||
|
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
||||||
|
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative-luminance crossover where white text starts to out-contrast black.
|
||||||
|
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
||||||
|
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
||||||
|
*/
|
||||||
|
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
||||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications
|
|||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
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.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
|||||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
EventFormField.Description -> R.string.event_detail_description
|
||||||
|
EventFormField.Timezone -> R.string.event_detail_timezone
|
||||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||||
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
|
||||||
@@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||||
EventFormField.Location -> Icons.Default.Place
|
EventFormField.Location -> Icons.Default.Place
|
||||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||||
|
EventFormField.Timezone -> Icons.Default.Public
|
||||||
EventFormField.Reminders -> Icons.Default.Notifications
|
EventFormField.Reminders -> Icons.Default.Notifications
|
||||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
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.consumeWindowInsets
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
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.TopAppBar
|
|
||||||
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. By default a collapsing
|
|
||||||
* [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
|
|
||||||
* the content scrolls — used by Settings and the calendar manager, where the
|
|
||||||
* large header sets the page. Content is a scrollable column that feeds the
|
|
||||||
* toolbar via nested scroll.
|
|
||||||
*
|
|
||||||
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
|
|
||||||
* title sits in the bar from the start, with no expanded header to scroll past.
|
|
||||||
* Preferred for selection pickers, where the tall header is just wasted space
|
|
||||||
* above the options.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun CollapsingScaffold(
|
|
||||||
title: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
largeTopBar: Boolean = true,
|
|
||||||
snackbarHost: @Composable () -> Unit = {},
|
|
||||||
actions: @Composable RowScope.() -> Unit = {},
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val scrollBehavior = if (largeTopBar) {
|
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
|
||||||
} else {
|
|
||||||
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
}
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier
|
|
||||||
.predictiveBack(onBack = onBack)
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
topBar = {
|
|
||||||
val navigationIcon = @Composable {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.settings_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
)
|
|
||||||
if (largeTopBar) {
|
|
||||||
LargeTopAppBar(
|
|
||||||
title = { Text(title) },
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
actions = actions,
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
colors = colors,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(title) },
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
actions = actions,
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
colors = colors,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
snackbarHost = snackbarHost,
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
// Mark the scaffold's system-bar insets as consumed so the
|
|
||||||
// imePadding below adds only the keyboard height beyond them
|
|
||||||
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
|
||||||
// leaves an empty strip above the keyboard.
|
|
||||||
.consumeWindowInsets(innerPadding)
|
|
||||||
.fillMaxSize()
|
|
||||||
// Paint the surface across the full area before imePadding carves
|
|
||||||
// into it, so any sliver above the keyboard reads as surface — not
|
|
||||||
// the dialog window's black — during the IME animation.
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
// Shrink the scroll viewport by the keyboard inset so a focused
|
|
||||||
// field (e.g. the custom-reminder amount) can scroll into view.
|
|
||||||
.imePadding()
|
|
||||||
.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). [dimmed] fades the headline and
|
|
||||||
* summary to the M3 disabled emphasis while leaving the [trailing] control at
|
|
||||||
* full opacity — for rows that are present but switched off.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun GroupedRow(
|
|
||||||
title: String,
|
|
||||||
position: Position,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
summary: String? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
dimmed: Boolean = false,
|
|
||||||
container: Color? = null,
|
|
||||||
minHeight: Dp = 72.dp,
|
|
||||||
// The 2.dp separation between rows in a run. Suppressed by the reorderable
|
|
||||||
// list, which owns uniform spacing itself so every slot has the same pitch.
|
|
||||||
gapBelow: Boolean = true,
|
|
||||||
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 {
|
|
||||||
!gapBelow -> Modifier
|
|
||||||
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
|
||||||
else -> 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 if (dimmed) {
|
|
||||||
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
|
|
||||||
// stays full-opacity so the toggle reads as live even on a faded row.
|
|
||||||
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
headlineColor = muted,
|
|
||||||
leadingIconColor = muted,
|
|
||||||
supportingColor = muted,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
|
||||||
}
|
|
||||||
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 = when {
|
|
||||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
container != null -> container
|
|
||||||
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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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.KeyboardActions
|
|
||||||
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.ImeAction
|
|
||||||
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,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
|
||||||
imeAction: ImeAction = ImeAction.Default,
|
|
||||||
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
|
||||||
onImeAction: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val resolvedStyle = textStyle.copy(
|
|
||||||
color = when {
|
|
||||||
// A disabled field reads as dimmed, like a locked value.
|
|
||||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
textStyle.color.isSpecified -> textStyle.color
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
)
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
enabled = enabled,
|
|
||||||
textStyle = resolvedStyle,
|
|
||||||
singleLine = singleLine,
|
|
||||||
minLines = minLines,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = keyboardType,
|
|
||||||
capitalization = capitalization,
|
|
||||||
imeAction = imeAction,
|
|
||||||
),
|
|
||||||
keyboardActions = onImeAction?.let { action ->
|
|
||||||
KeyboardActions(onAny = { action() })
|
|
||||||
} ?: KeyboardActions.Default,
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box {
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.core.os.ConfigurationCompat
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current display [Locale], read observably from [LocalConfiguration] so the UI
|
|
||||||
* recomposes after a locale change (lint: NonObservableLocale). Used for
|
|
||||||
* weekday/month name formatting.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun currentLocale(): Locale {
|
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
return remember(configuration) {
|
|
||||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's standard pick in a selection dialog: a full-width tonal card,
|
|
||||||
* optionally with a leading icon and a supporting line; the selected option
|
|
||||||
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
|
|
||||||
* only sanctioned selection-modal style (no radio rows, no bare text lists).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun OptionCard(
|
|
||||||
label: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
icon: ImageVector? = null,
|
|
||||||
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
|
|
||||||
iconTint: Color = Color.Unspecified,
|
|
||||||
supportingText: String? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
|
|
||||||
labelColor: Color = Color.Unspecified,
|
|
||||||
) {
|
|
||||||
val contentColor = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
color = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
|
||||||
) {
|
|
||||||
if (icon != null) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = when {
|
|
||||||
iconTint.isSpecified -> iconTint
|
|
||||||
selected -> MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = if (labelColor.isSpecified) labelColor else contentColor,
|
|
||||||
)
|
|
||||||
if (supportingText != null) {
|
|
||||||
Text(
|
|
||||||
text = supportingText,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +1,39 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
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.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SegmentedButton
|
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
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.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
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 androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
import androidx.compose.ui.window.DialogWindowProvider
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
|
||||||
* reuses the app's [CollapsingScaffold] (back button + full width), but with a
|
|
||||||
* pinned single-line title rather than the large collapsing header — a picker is
|
|
||||||
* a short list, so the tall header would only be empty space to scroll past.
|
|
||||||
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FullScreenPicker(
|
|
||||||
title: String,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
properties = DialogProperties(
|
|
||||||
usePlatformDefaultWidth = false,
|
|
||||||
decorFitsSystemWindows = false,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
// The dialog window pans by default when the keyboard opens, which —
|
|
||||||
// combined with the content's own imePadding — leaves a fixed black gap
|
|
||||||
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
|
||||||
// full-screen and imePadding alone lifts the focused field.
|
|
||||||
val view = LocalView.current
|
|
||||||
SideEffect {
|
|
||||||
(view.parent as? DialogWindowProvider)?.window
|
|
||||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
|
||||||
}
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = title,
|
|
||||||
onBack = onDismiss,
|
|
||||||
largeTopBar = false,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* General single-select picker, full-screen: each option is a connected grouped
|
|
||||||
* row and the current one carries a check. Drop-in for the former dialog
|
|
||||||
* (theme, week start, language, …).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun <T> OptionPicker(
|
|
||||||
title: String,
|
|
||||||
options: List<T>,
|
|
||||||
selected: T,
|
|
||||||
label: @Composable (T) -> String,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
|
||||||
header?.invoke(this)
|
|
||||||
options.forEachIndexed { index, option ->
|
|
||||||
val isSelected = option == selected
|
|
||||||
GroupedRow(
|
|
||||||
title = label(option),
|
|
||||||
position = positionOf(index, options.size),
|
|
||||||
selected = isSelected,
|
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
onSelect(option)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||||
@@ -128,7 +43,7 @@ fun <T> OptionPicker(
|
|||||||
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||||
* defers to the global default. Clearing the last checked time reverts to "Use
|
* defers to the global default. Clearing the last checked time reverts to "Use
|
||||||
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
||||||
* wipe the calendar's default) and to explicit [CalendarReminderOverride.None]
|
* wipe the calendar's default) and to explicit [ReminderOverride.None]
|
||||||
* on the global default (where empty legitimately means no reminder). A "Custom"
|
* on the global default (where empty legitimately means no reminder). A "Custom"
|
||||||
* row expands an inline number field plus a unit selector to add an arbitrary
|
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||||
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||||
@@ -138,9 +53,9 @@ fun <T> OptionPicker(
|
|||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
title: String,
|
title: String,
|
||||||
presets: List<Int>,
|
presets: List<Int>,
|
||||||
selected: CalendarReminderOverride,
|
selected: ReminderOverride,
|
||||||
allowInherit: Boolean,
|
allowInherit: Boolean,
|
||||||
onSelect: (CalendarReminderOverride) -> Unit,
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Optimistic local state: once the user edits, the chosen override is
|
// Optimistic local state: once the user edits, the chosen override is
|
||||||
@@ -158,9 +73,9 @@ fun ReminderDefaultPicker(
|
|||||||
LaunchedEffect(selected) {
|
LaunchedEffect(selected) {
|
||||||
if (!userEdited) current = selected
|
if (!userEdited) current = selected
|
||||||
}
|
}
|
||||||
val inherits = current is CalendarReminderOverride.Inherit
|
val inherits = current is ReminderOverride.Inherit
|
||||||
val isNone = current is CalendarReminderOverride.None
|
val isNone = current is ReminderOverride.None
|
||||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||||
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
||||||
// which would strand a hand-entered value with no way to re-check it.
|
// which would strand a hand-entered value with no way to re-check it.
|
||||||
@@ -173,7 +88,7 @@ fun ReminderDefaultPicker(
|
|||||||
var amountText by rememberSaveable { mutableStateOf("") }
|
var amountText by rememberSaveable { mutableStateOf("") }
|
||||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||||
|
|
||||||
fun apply(override: CalendarReminderOverride) {
|
fun apply(override: ReminderOverride) {
|
||||||
userEdited = true
|
userEdited = true
|
||||||
current = override
|
current = override
|
||||||
onSelect(override)
|
onSelect(override)
|
||||||
@@ -197,7 +112,7 @@ fun ReminderDefaultPicker(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
onClick = { apply(ReminderOverride.Inherit) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -209,7 +124,7 @@ fun ReminderDefaultPicker(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { apply(CalendarReminderOverride.None) },
|
onClick = { apply(ReminderOverride.None) },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
val rowCount = rows.size + 1 // + the custom row
|
val rowCount = rows.size + 1 // + the custom row
|
||||||
@@ -232,8 +147,8 @@ fun ReminderDefaultPicker(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomReminderEditor(
|
CustomReminderEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -250,26 +165,6 @@ fun ReminderDefaultPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The override a lead-time set maps to when emitted from [ReminderDefaultPicker].
|
|
||||||
* A non-empty set is [CalendarReminderOverride.Minutes]; an empty set (the last
|
|
||||||
* time was unchecked) reverts to [CalendarReminderOverride.Inherit] on a
|
|
||||||
* per-calendar picker ([allowInherit]) so an accidental toggle-undo can't
|
|
||||||
* silently wipe the calendar's default, and to explicit
|
|
||||||
* [CalendarReminderOverride.None] on the global default (where empty legitimately
|
|
||||||
* means no reminder). Pure so it can be unit-tested.
|
|
||||||
*/
|
|
||||||
internal fun reminderOverrideForMinutes(
|
|
||||||
minutes: List<Int>,
|
|
||||||
allowInherit: Boolean,
|
|
||||||
): CalendarReminderOverride {
|
|
||||||
val norm = minutes.distinct().sorted()
|
|
||||||
return when {
|
|
||||||
norm.isNotEmpty() -> CalendarReminderOverride.Minutes(norm)
|
|
||||||
allowInherit -> CalendarReminderOverride.Inherit
|
|
||||||
else -> CalendarReminderOverride.None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||||
@@ -287,61 +182,23 @@ private fun CustomReminderEditor(
|
|||||||
onConfirm: (Int) -> Unit,
|
onConfirm: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
Surface(
|
CustomAmountEditor(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
amountText = amountText,
|
||||||
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
onAmountChange = onAmountChange,
|
||||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||||
modifier = Modifier
|
selectedUnit = unit.ordinal,
|
||||||
.fillMaxWidth()
|
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
|
||||||
.padding(horizontal = 16.dp),
|
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
// Unit toggle first so it stays visible above the keyboard once the
|
|
||||||
// amount field (the bottom row) is focused and scrolled into view.
|
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
ReminderUnit.entries.forEachIndexed { index, entry ->
|
|
||||||
SegmentedButton(
|
|
||||||
selected = unit == entry,
|
|
||||||
onClick = { onUnitChange(entry) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
|
||||||
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Amount, a live preview of the lead time it resolves to, and Set —
|
|
||||||
// all on one row, sitting just above the keyboard.
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
DialogAmountField(
|
|
||||||
value = amountText,
|
|
||||||
onValueChange = onAmountChange,
|
|
||||||
placeholder = "10",
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Text(
|
|
||||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
|
||||||
?: stringResource(R.string.reminder_custom_amount),
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
confirmEnabled = amount != null,
|
||||||
modifier = Modifier.weight(1f),
|
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
|
||||||
enabled = amount != null,
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.reminder_custom_set))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PickerDescription(text: String) {
|
internal fun PickerDescription(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -350,14 +207,6 @@ private fun PickerDescription(text: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SelectedCheck() {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||||
@@ -432,8 +281,8 @@ fun AgendaRangePicker(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomDaysEditor(
|
CustomDaysEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -456,41 +305,136 @@ private fun CustomDaysEditor(
|
|||||||
) {
|
) {
|
||||||
val days = amountText.toIntOrNull()
|
val days = amountText.toIntOrNull()
|
||||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||||
Surface(
|
CustomAmountEditor(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
amountText = amountText,
|
||||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
onAmountChange = onAmountChange,
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
DialogAmountField(
|
|
||||||
value = amountText,
|
|
||||||
onValueChange = onAmountChange,
|
|
||||||
placeholder = "30",
|
placeholder = "30",
|
||||||
)
|
preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Text(
|
|
||||||
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
|
||||||
?: stringResource(R.string.agenda_range_custom_hint),
|
?: stringResource(R.string.agenda_range_custom_hint),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
confirmEnabled = days != null,
|
||||||
modifier = Modifier.weight(1f),
|
onConfirm = { days?.let(onConfirm) },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(16.dp))
|
}
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { days?.let(onConfirm) },
|
/**
|
||||||
enabled = days != null,
|
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
||||||
|
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
||||||
|
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
||||||
|
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
||||||
|
* a preset or confirming a custom value applies via [onSelect] and closes.
|
||||||
|
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
||||||
|
* is reused for both the rows and the custom preview.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SnoozeDurationPicker(
|
||||||
|
title: String,
|
||||||
|
presets: List<Int>,
|
||||||
|
selected: Int,
|
||||||
|
label: @Composable (Int) -> String,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val customSelected = selected !in presets
|
||||||
|
val rowCount = presets.size + 1 // + the custom row
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable {
|
||||||
|
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
||||||
|
}
|
||||||
|
var unit by rememberSaveable {
|
||||||
|
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||||
|
presets.forEachIndexed { index, minute ->
|
||||||
|
val isSelected = minute == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = label(minute),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(minute)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The Custom row connects downward into the editor card when expanded, so
|
||||||
|
// the two read as one grouped container (the shared custom-expand pattern).
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
trailing = if (customSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = customExpanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.reminder_custom_set))
|
CustomSnoozeEditor(
|
||||||
}
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
label = label,
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(minutes)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whole hours if the delay divides evenly, else minutes. */
|
||||||
|
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
||||||
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||||
|
|
||||||
|
private fun snoozeCustomAmount(minutes: Int): Int =
|
||||||
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
||||||
|
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||||
|
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||||
|
* amount. [onConfirm] receives the final delay in minutes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CustomSnoozeEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
unit: ReminderUnit,
|
||||||
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
|
label: @Composable (Int) -> String,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
||||||
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
CustomAmountEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = onAmountChange,
|
||||||
|
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||||
|
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
||||||
|
onUnitChange = { onUnitChange(units[it]) },
|
||||||
|
preview = amount?.let { label(it * unit.minutesFactor) }
|
||||||
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
|
confirmEnabled = amount != null,
|
||||||
|
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||||
@Composable
|
@Composable
|
||||||
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import androidx.compose.ui.text.buildAnnotatedString
|
|||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
@@ -111,16 +115,36 @@ private fun rruleDayName(token: String, locale: Locale): String? {
|
|||||||
|
|
||||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||||
val digits = raw.takeWhile { it.isDigit() }
|
val date = untilLocalDate(raw) ?: return null
|
||||||
if (digits.length < 8) return null
|
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||||
return try {
|
}
|
||||||
val date = java.time.LocalDate.of(
|
|
||||||
|
private val UNTIL_UTC_FORMAT: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar day an RRULE UNTIL value denotes, *in the device's zone*.
|
||||||
|
*
|
||||||
|
* The UTC form ("20261231T225959Z") must be converted back before its date is
|
||||||
|
* read: `SimpleRecurrence.toRRule` writes the end of the chosen **local** day
|
||||||
|
* expressed in UTC, so for zones behind UTC that instant already falls on the
|
||||||
|
* following UTC date. Taking the leading digits straight off would then show
|
||||||
|
* the day after the one the user picked. Date-only and floating forms are
|
||||||
|
* already local and pass through unchanged.
|
||||||
|
*/
|
||||||
|
internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): LocalDate? {
|
||||||
|
val value = raw.trim()
|
||||||
|
return runCatching {
|
||||||
|
LocalDateTime.parse(value, UNTIL_UTC_FORMAT)
|
||||||
|
.atOffset(ZoneOffset.UTC)
|
||||||
|
.atZoneSameInstant(zone)
|
||||||
|
.toLocalDate()
|
||||||
|
}.recoverCatching {
|
||||||
|
val digits = value.takeWhile { it.isDigit() }
|
||||||
|
LocalDate.of(
|
||||||
digits.substring(0, 4).toInt(),
|
digits.substring(0, 4).toInt(),
|
||||||
digits.substring(4, 6).toInt(),
|
digits.substring(4, 6).toInt(),
|
||||||
digits.substring(6, 8).toInt(),
|
digits.substring(6, 8).toInt(),
|
||||||
)
|
)
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
}.getOrNull()
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the form and settings. */
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
|
||||||
enum class ReminderUnit(val minutesFactor: Int) {
|
|
||||||
Minutes(1),
|
|
||||||
Hours(60),
|
|
||||||
Days(1_440),
|
|
||||||
Weeks(10_080),
|
|
||||||
}
|
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.Spring
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.snap
|
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
|
||||||
val ReorderableRowHeight: Dp = 64.dp
|
|
||||||
private val RowGap: Dp = 2.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A vertical list whose rows can be dragged into a new order by their handle.
|
|
||||||
*
|
|
||||||
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
|
|
||||||
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
|
|
||||||
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
|
|
||||||
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
|
||||||
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
|
||||||
* been dragged. The held row follows the finger while the others slide out of
|
|
||||||
* its way (animated); on release the order is committed immediately with a
|
|
||||||
* single [onReorder] call, then the held row eases into its new slot as a
|
|
||||||
* purely visual settle — safe to interrupt with another drag, since the
|
|
||||||
* commit itself never waits on it.
|
|
||||||
*
|
|
||||||
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
|
||||||
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
|
||||||
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun <T> ReorderableColumn(
|
|
||||||
items: List<T>,
|
|
||||||
keyOf: (T) -> Any,
|
|
||||||
onReorder: (List<T>) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
// Local working copy; re-seeded when the incoming list changes (including the
|
|
||||||
// echo of our own committed order).
|
|
||||||
var order by remember(items) { mutableStateOf(items) }
|
|
||||||
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
|
||||||
// Live translation of the held row from its slot (px); also drives the
|
|
||||||
// release settle — re-based onto the row's new slot once the order commits
|
|
||||||
// (see onDragEnd), then eased down to zero.
|
|
||||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
|
||||||
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
|
||||||
// Purely visual — the order commit (see onDragEnd) never depends on it.
|
|
||||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
|
||||||
|
|
||||||
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
|
||||||
// Whole slots dragged → the slot the held row currently hovers over. Derived
|
|
||||||
// so recomposition only fires when the *target slot* actually changes, not on
|
|
||||||
// every dragged pixel: dragOffset moves every frame during a drag, but the
|
|
||||||
// held row's own translation is already applied in the draw phase via
|
|
||||||
// graphicsLayer below, so only the neighbours' shift (which depends on this)
|
|
||||||
// needs to recompose, and only when a slot boundary is actually crossed.
|
|
||||||
// (Read as a plain val, not `by`, below: a delegated property has a custom
|
|
||||||
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
|
|
||||||
// the loop, so the derived value is captured into a real local instead.)
|
|
||||||
val targetIndex = remember(items, pitchPx) {
|
|
||||||
derivedStateOf {
|
|
||||||
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
|
||||||
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
|
|
||||||
}
|
|
||||||
}.value
|
|
||||||
|
|
||||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
|
||||||
order.forEachIndexed { index, item ->
|
|
||||||
val key = keyOf(item)
|
|
||||||
val isDragged = key == draggedKey
|
|
||||||
|
|
||||||
// Slide neighbours by one pitch to open the gap the held row will drop
|
|
||||||
// into. Snap (not animate) once idle, so committing the new order — which
|
|
||||||
// moves each row's slot — doesn't visibly fight a lingering animation.
|
|
||||||
val shift = when {
|
|
||||||
draggedIndex == null || targetIndex == null || isDragged -> 0f
|
|
||||||
index in (draggedIndex + 1)..targetIndex -> -pitchPx
|
|
||||||
index in targetIndex until draggedIndex -> pitchPx
|
|
||||||
else -> 0f
|
|
||||||
}
|
|
||||||
val animatedShift by animateFloatAsState(
|
|
||||||
targetValue = shift,
|
|
||||||
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
|
|
||||||
label = "reorderShift",
|
|
||||||
)
|
|
||||||
|
|
||||||
val dragHandle = Modifier.pointerInput(key) {
|
|
||||||
detectDragGestures(
|
|
||||||
onDragStart = {
|
|
||||||
settleJob?.cancel()
|
|
||||||
draggedKey = key
|
|
||||||
dragOffset = 0f
|
|
||||||
},
|
|
||||||
onDrag = { change, amount ->
|
|
||||||
change.consume()
|
|
||||||
dragOffset += amount.y
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
val from = order.indexOfFirst { keyOf(it) == key }
|
|
||||||
if (from < 0) return@detectDragGestures
|
|
||||||
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
|
||||||
if (to != from) {
|
|
||||||
// Commit synchronously and unconditionally, before the settle
|
|
||||||
// animation below runs — the commit must not depend on that
|
|
||||||
// coroutine reaching its end, or a new drag starting within the
|
|
||||||
// ~160ms settle window would cancel it and silently revert an
|
|
||||||
// already-finished reorder.
|
|
||||||
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
|
||||||
onReorder(order)
|
|
||||||
// The row now lays out at slot `to` instead of `from`; re-base
|
|
||||||
// the live offset onto that new slot (same visual position,
|
|
||||||
// expressed relative to the new one) so the settle below eases
|
|
||||||
// it the rest of the way instead of jumping.
|
|
||||||
dragOffset -= (to - from) * pitchPx
|
|
||||||
}
|
|
||||||
settleJob = scope.launch {
|
|
||||||
// Purely visual from here: ease the held row onto its slot, then
|
|
||||||
// release the drag state. Safe to cancel — the order was already
|
|
||||||
// committed above.
|
|
||||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
|
||||||
draggedKey = null
|
|
||||||
dragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDragCancel = {
|
|
||||||
settleJob = scope.launch {
|
|
||||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
|
||||||
draggedKey = null
|
|
||||||
dragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.height(ReorderableRowHeight)
|
|
||||||
.zIndex(if (isDragged) 1f else 0f)
|
|
||||||
.graphicsLayer {
|
|
||||||
translationY = if (isDragged) dragOffset else animatedShift
|
|
||||||
if (isDragged) {
|
|
||||||
scaleX = 1.02f
|
|
||||||
scaleY = 1.02f
|
|
||||||
shadowElevation = 8.dp.toPx()
|
|
||||||
shape = RoundedCornerShape(20.dp)
|
|
||||||
clip = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Resources
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.text.format.DateFormat
|
|
||||||
import androidx.compose.material3.TimePicker
|
import androidx.compose.material3.TimePicker
|
||||||
import androidx.compose.material3.rememberTimePickerState
|
import androidx.compose.material3.rememberTimePickerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -27,10 +22,13 @@ fun TimePickerAlert(
|
|||||||
onConfirm: (LocalTime) -> Unit,
|
onConfirm: (LocalTime) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
// Honour the app's own time-format preference (the value every time label
|
||||||
|
// reads), not the system/locale clock — otherwise an explicit 24h setting
|
||||||
|
// still showed an AM/PM dial (issue #27).
|
||||||
val state = rememberTimePickerState(
|
val state = rememberTimePickerState(
|
||||||
initialHour = initial.hour,
|
initialHour = initial.hour,
|
||||||
initialMinute = initial.minute,
|
initialMinute = initial.minute,
|
||||||
is24Hour = deviceUses24HourClock(LocalContext.current),
|
is24Hour = LocalUse24HourFormat.current,
|
||||||
)
|
)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -45,24 +43,3 @@ fun TimePickerAlert(
|
|||||||
text = { TimePicker(state = state) },
|
text = { TimePicker(state = state) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the clock should read 24-hour, matching the rest of the device.
|
|
||||||
*
|
|
||||||
* [DateFormat.is24HourFormat] resolves a "locale default" system setting against
|
|
||||||
* the *app's* context locale — and this app applies a per-app language
|
|
||||||
* (AppCompatDelegate), so an English UI on a German-region phone would wrongly
|
|
||||||
* read 12-hour while the system clock shows 24-hour. So we honour an explicit
|
|
||||||
* system 12/24 override, and otherwise fall back to the **device** locale
|
|
||||||
* (Resources.getSystem), not the app's.
|
|
||||||
*/
|
|
||||||
private fun deviceUses24HourClock(context: Context): Boolean =
|
|
||||||
when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) {
|
|
||||||
"24" -> true
|
|
||||||
"12" -> false
|
|
||||||
// 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour.
|
|
||||||
else -> {
|
|
||||||
val deviceLocale = Resources.getSystem().configuration.locales[0]
|
|
||||||
!DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
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.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.filterTimeZones
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||||
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen zone picker: a query field over every zone the JVM knows, with the
|
||||||
|
* device zone and recently-picked ones pinned on top so the common case needs no
|
||||||
|
* typing. [selected] is the pinned zone id, or null when the event follows the
|
||||||
|
* device — picking the device row reports null back, which is what keeps an
|
||||||
|
* unpinned event unpinned rather than freezing it to today's zone.
|
||||||
|
*
|
||||||
|
* Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this
|
||||||
|
* drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far
|
||||||
|
* past what an eagerly composed column can carry.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TimeZonePickerDialog(
|
||||||
|
selected: String?,
|
||||||
|
deviceZoneId: String,
|
||||||
|
recents: List<String>,
|
||||||
|
onSelect: (String?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
val locale = currentLocale()
|
||||||
|
// ~600 zones, each resolving a localized name and up to two ICU short names:
|
||||||
|
// far too much to run inside composition, so build it on a worker and let the
|
||||||
|
// list fill in a frame later. Filtering the built catalogue is cheap (its
|
||||||
|
// search keys are pre-normalized), so that stays here.
|
||||||
|
val allZones by produceState(initialValue = emptyList<TimeZoneOption>(), locale) {
|
||||||
|
value = withContext(Dispatchers.Default) {
|
||||||
|
timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val matches = remember(allZones, query) { filterTimeZones(allZones, query) }
|
||||||
|
val recentZones = remember(allZones, recents) {
|
||||||
|
recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } }
|
||||||
|
}
|
||||||
|
// Resolved on its own rather than looked up in the catalogue: it's one zone,
|
||||||
|
// so it costs nothing, and the device row can then render complete on the
|
||||||
|
// first frame instead of showing a bare id until the catalogue lands.
|
||||||
|
val deviceSummary = remember(deviceZoneId, locale) {
|
||||||
|
timeZoneOptionOf(deviceZoneId, locale, regionOf = ::icuTimeZoneRegion)
|
||||||
|
?.let { zoneDescriptor(it) }
|
||||||
|
?: deviceZoneId
|
||||||
|
}
|
||||||
|
val searching = query.isNotBlank()
|
||||||
|
|
||||||
|
fun choose(zoneId: String?) {
|
||||||
|
onSelect(zoneId)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.event_detail_timezone),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
scrollable = false,
|
||||||
|
) {
|
||||||
|
SearchField(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = { query = it },
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||||
|
if (!searching) {
|
||||||
|
// The device row is the "unpinned" choice, so it reads as a mode
|
||||||
|
// rather than as a zone among zones — it stays out of the big
|
||||||
|
// list and never carries an offset.
|
||||||
|
item(key = "device") {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_timezone_device),
|
||||||
|
summary = deviceSummary,
|
||||||
|
position = Position.Alone,
|
||||||
|
selected = selected == null,
|
||||||
|
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
||||||
|
trailing = if (selected == null) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { choose(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (recentZones.isNotEmpty()) {
|
||||||
|
item(key = "recent_header") {
|
||||||
|
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
||||||
|
}
|
||||||
|
itemsIndexed(
|
||||||
|
items = recentZones,
|
||||||
|
key = { _, zone -> "recent_${zone.id}" },
|
||||||
|
) { index, zone ->
|
||||||
|
ZoneRow(
|
||||||
|
zone = zone,
|
||||||
|
position = positionOf(index, recentZones.size),
|
||||||
|
selected = zone.id == selected,
|
||||||
|
onClick = { choose(zone.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "all_header") {
|
||||||
|
SectionHeader(stringResource(R.string.event_edit_timezone_all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allZones.isNotEmpty() gates this: until the catalogue lands there is
|
||||||
|
// simply nothing to match yet, and claiming "no time zone matches"
|
||||||
|
// for that frame would be wrong.
|
||||||
|
if (searching && matches.isEmpty() && allZones.isNotEmpty()) {
|
||||||
|
item(key = "empty") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_timezone_none, query),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp, vertical = 48.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsIndexed(
|
||||||
|
items = matches,
|
||||||
|
key = { _, zone -> "zone_${zone.id}" },
|
||||||
|
) { index, zone ->
|
||||||
|
ZoneRow(
|
||||||
|
zone = zone,
|
||||||
|
position = positionOf(index, matches.size),
|
||||||
|
selected = zone.id == selected,
|
||||||
|
onClick = { choose(zone.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query box: the family's borderless [InlineTextField] over a tonal surface,
|
||||||
|
* so it reads like the rest of the app's inputs rather than a boxed Material
|
||||||
|
* field. The clear button rides inside the surface (unlike the search screen,
|
||||||
|
* which has a top bar to put it in) because the picker's bar is the title.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SearchField(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
// surfaceContainerHighest — the picker sits on surfaceContainerHigh, so
|
||||||
|
// anything lower vanishes into it.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
InlineTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = stringResource(R.string.event_edit_timezone_search),
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
onImeAction = { keyboard?.hide() },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_clear),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small primary-coloured group label, matching the settings screens. */
|
||||||
|
@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 ZoneRow(
|
||||||
|
zone: TimeZoneOption,
|
||||||
|
position: Position,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = zone.label,
|
||||||
|
summary = zoneDescriptor(zone),
|
||||||
|
position = position,
|
||||||
|
selected = selected,
|
||||||
|
trailing = if (selected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.icu.util.TimeZone as IcuTimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ISO 3166 region an IANA zone belongs to ("America/New_York" -> "US"), or
|
||||||
|
* null when ICU has none or maps it to the multi-country "001" ("Etc/UTC").
|
||||||
|
*
|
||||||
|
* This is the `android.icu`-backed bridge the pure-JVM
|
||||||
|
* [de.jeanlucmakiola.calendula.domain.timeZoneOptions] takes as `regionOf`, so
|
||||||
|
* it can ask ICU for a zone's abbreviation in that zone's own region — the only
|
||||||
|
* region for which ICU will surface it (see `resolveAbbreviation`). Lives here,
|
||||||
|
* not in `domain/`, precisely because it needs an Android import.
|
||||||
|
*/
|
||||||
|
fun icuTimeZoneRegion(id: String): String? =
|
||||||
|
runCatching { IcuTimeZone.getRegion(id) }.getOrNull()?.takeIf { it.length == 2 }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Today
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top-bar "jump to today" icon button, shared by every calendar view's app
|
||||||
|
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
||||||
|
* today control into the toolbar; renders nothing when [show] is false, so it
|
||||||
|
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
||||||
|
if (!show) return
|
||||||
|
IconButton(onClick = onToday) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Today,
|
||||||
|
contentDescription = stringResource(R.string.today_jump_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A deliberately minimal, standalone surface for a captured crash report.
|
* A deliberately minimal, standalone surface for a captured crash report.
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.crash
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
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.filled.BugReport
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asks the user to send a captured crash report as an issue. The full report is
|
|
||||||
* shown verbatim in a scrollable panel — the user sees exactly what will leave
|
|
||||||
* the device before choosing to share it (the privacy backstop). [onSend] hands
|
|
||||||
* off to [submitCrashReport]; [onDismiss] declines.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CrashReportDialog(
|
|
||||||
report: String,
|
|
||||||
onSend: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
|
|
||||||
title = { Text(stringResource(R.string.crash_dialog_title)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.crash_dialog_message),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = report,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.heightIn(max = 220.dp)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.crash
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hand the captured crash report off to the user's chosen channel: the report
|
|
||||||
* is copied to the clipboard (the reliable path for a full stack trace) and the
|
|
||||||
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
|
|
||||||
* sent automatically — the app has no network access; the user reviews and
|
|
||||||
* submits the issue themselves.
|
|
||||||
*/
|
|
||||||
fun submitCrashReport(context: Context, report: String) {
|
|
||||||
copyReportToClipboard(context, report)
|
|
||||||
val opened = runCatching {
|
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
|
|
||||||
}.isSuccess
|
|
||||||
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
|
|
||||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
|
|
||||||
fun openIssueTracker(context: Context) {
|
|
||||||
val uri = context.getString(R.string.report_issue_choose_url).toUri()
|
|
||||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyReportToClipboard(context: Context, report: String) {
|
|
||||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
|
||||||
val label = context.getString(R.string.crash_report_clip_label)
|
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
|
|
||||||
* can blow past URL-length limits, so an over-long one is left out of the link
|
|
||||||
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
|
|
||||||
* source of truth in that case.
|
|
||||||
*/
|
|
||||||
private fun buildIssueUri(context: Context, report: String) =
|
|
||||||
context.getString(R.string.report_issue_url).toUri().buildUpon()
|
|
||||||
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
|
|
||||||
.appendQueryParameter("body", buildIssueBody(context, report))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun buildIssueBody(context: Context, report: String): String {
|
|
||||||
val block = if (report.length > MAX_URL_REPORT_CHARS) {
|
|
||||||
context.getString(R.string.crash_report_body_paste)
|
|
||||||
} else {
|
|
||||||
"```\n$report\n```"
|
|
||||||
}
|
|
||||||
return context.getString(R.string.crash_report_body_template, block)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep the prefilled body comfortably under common URL-length ceilings. */
|
|
||||||
private const val MAX_URL_REPORT_CHARS = 6_000
|
|
||||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
|
|||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
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
|
||||||
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -68,8 +66,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
@@ -77,11 +77,14 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
@@ -91,12 +94,18 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
private val HOUR_HEIGHT = 56.dp
|
||||||
private val GUTTER_WIDTH = 48.dp
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||||
|
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||||
|
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||||
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
private val MIN_EVENT_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
@@ -119,6 +128,7 @@ fun DayScreen(
|
|||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -151,6 +161,13 @@ fun DayScreen(
|
|||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drives whether the title carries the year. Falls back to the clock only
|
||||||
|
// while the first load is in flight, when there is no state to read today from.
|
||||||
|
val currentYear = when (val s = state) {
|
||||||
|
is DayUiState.Success -> s.today.year
|
||||||
|
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||||
|
}
|
||||||
|
|
||||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
@@ -199,16 +216,19 @@ fun DayScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnToday,
|
todayVisible = !isOnToday && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.day_today_action),
|
todayText = stringResource(R.string.day_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = { onCreateEvent(date, null) },
|
onCreate = { onCreateEvent(date, null) },
|
||||||
@@ -245,8 +265,6 @@ private fun DayContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val threshold = with(density) { 24.dp.toPx() }
|
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -275,20 +293,7 @@ private fun DayContent(
|
|||||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||||
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
||||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { dragAccum = 0f },
|
|
||||||
onDragEnd = {
|
|
||||||
when {
|
|
||||||
dragAccum < -threshold -> onSwipeNext()
|
|
||||||
dragAccum > threshold -> onSwipePrev()
|
|
||||||
}
|
|
||||||
dragAccum = 0f
|
|
||||||
},
|
|
||||||
onDragCancel = { dragAccum = 0f },
|
|
||||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
@@ -354,16 +359,20 @@ private fun DaySuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DayTopBar(
|
private fun DayTopBar(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = formatDayTitle(date),
|
text = formatDayTitle(date, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -376,6 +385,7 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -448,9 +458,11 @@ private fun AllDayBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
@@ -461,7 +473,7 @@ private fun AllDayBar(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.8f),
|
color = eventInk(fill),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,10 +496,12 @@ private fun Timeline(
|
|||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position.
|
// soft corners are permanent at any scroll position.
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
// Hour gutter (scrolls in sync with the day column)
|
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||||
|
// labels centre on the top bar hamburger, matching the week view.
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(GUTTER_WIDTH)
|
.width(GUTTER_WIDTH)
|
||||||
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
@@ -610,9 +624,11 @@ private fun EventBlock(
|
|||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val showTime = block.endMin - block.startMin >= 45
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
@@ -623,7 +639,7 @@ private fun EventBlock(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = if (showTime) 1 else 2,
|
maxLines = if (showTime) 1 else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
)
|
)
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
Text(
|
||||||
@@ -631,7 +647,7 @@ private fun EventBlock(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.6f),
|
color = eventInk(fill, alpha = 0.6f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -657,10 +673,10 @@ private fun DayLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate): String {
|
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
||||||
val locale = Locale.getDefault()
|
formatCalendarTitle(
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
locale = locale,
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
currentYear = currentYear,
|
||||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
skeleton = "EEEdMMM",
|
||||||
}
|
)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
@@ -87,25 +88,31 @@ import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
|||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -116,7 +123,10 @@ import kotlin.time.Instant
|
|||||||
* top-bar arrow both return to the calendar. Events in writable calendars can
|
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||||
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
||||||
* event form for this occurrence — for recurring events the form asks how
|
* event form for this occurrence — for recurring events the form asks how
|
||||||
* far the change reaches when saving.
|
* far the change reaches when saving. [onDuplicate] opens the same form
|
||||||
|
* seeded with a copy of this event as a new, unsaved event (#52) — offered
|
||||||
|
* for any event, including read-only ones, since the copy lands in an
|
||||||
|
* editable calendar.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -126,6 +136,7 @@ fun EventDetailScreen(
|
|||||||
endMillis: Long,
|
endMillis: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
|
onDuplicate: (EventForm) -> Unit,
|
||||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
@@ -160,15 +171,14 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the tapped action.
|
// upgrade in place. Granting continues straight into the tapped action —
|
||||||
var pendingEdit by remember { mutableStateOf(false) }
|
// edit, delete, or duplicate — held here until the result comes back.
|
||||||
|
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
if (granted) {
|
if (granted) pendingWrite?.invoke()
|
||||||
if (pendingEdit) onEdit() else showDeleteDialog = true
|
pendingWrite = null
|
||||||
}
|
|
||||||
pendingEdit = false
|
|
||||||
}
|
}
|
||||||
val hasWritePermission = {
|
val hasWritePermission = {
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
@@ -176,21 +186,20 @@ fun EventDetailScreen(
|
|||||||
Manifest.permission.WRITE_CALENDAR,
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
val onDeleteClick = {
|
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||||
if (hasWritePermission()) {
|
if (hasWritePermission()) {
|
||||||
showDeleteDialog = true
|
action()
|
||||||
} else {
|
} else {
|
||||||
pendingEdit = false
|
pendingWrite = action
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onEditClick = {
|
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||||
if (hasWritePermission()) {
|
val onEditClick = { requireWrite(onEdit) }
|
||||||
onEdit()
|
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||||
} else {
|
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||||
pendingEdit = true
|
val onDuplicateClick = {
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||||
@@ -229,7 +238,8 @@ fun EventDetailScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
val s = state
|
val s = state
|
||||||
// Share works for any loaded event — it only reads the event.
|
// Share and duplicate work for any loaded event — both only
|
||||||
|
// read it; the duplicate is created into a writable calendar.
|
||||||
if (s is EventDetailUiState.Success) {
|
if (s is EventDetailUiState.Success) {
|
||||||
IconButton(onClick = onShareClick) {
|
IconButton(onClick = onShareClick) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -237,6 +247,15 @@ fun EventDetailScreen(
|
|||||||
contentDescription = stringResource(R.string.event_detail_share),
|
contentDescription = stringResource(R.string.event_detail_share),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onDuplicateClick,
|
||||||
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_duplicate),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Edit/delete need a writable calendar — WebCal subscriptions,
|
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||||
// birthday calendars etc. are read-only at the provider level.
|
// birthday calendars etc. are read-only at the provider level.
|
||||||
@@ -367,7 +386,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
val instance = detail.instance
|
val instance = detail.instance
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val locale = currentDetailLocale()
|
val locale = currentDetailLocale()
|
||||||
val accent = pastelize(instance.color, dark)
|
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -438,14 +457,44 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Time zone — only when the event is timed and pinned to a zone other
|
// Time zone — only when the event is timed and pinned to a zone other
|
||||||
// than the device's, so cross-zone events read unambiguously.
|
// than the device's, so cross-zone events read unambiguously. It answers
|
||||||
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
// "when was it set", which the zone's name alone never did: an 8 AM New
|
||||||
|
// York call showing as 2 PM here should still say 8 AM somewhere.
|
||||||
|
//
|
||||||
|
// Same hierarchy as the When card above — the label carries the card,
|
||||||
|
// the time sits small beneath it. The local time is the one the reader
|
||||||
|
// acts on, so the original must stay quieter than it, not compete.
|
||||||
|
val foreignZone = remember(detail.eventTimezone, instance.isAllDay, instance.start, locale) {
|
||||||
|
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale, instance.start)
|
||||||
|
}
|
||||||
|
foreignZone?.let { zoneOption ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.Public,
|
icon = Icons.Default.Public,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||||
) {
|
) {
|
||||||
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
// The id is the primary label ("Europe/Berlin"); the long
|
||||||
|
// localized name spelled next to it made the field too wide.
|
||||||
|
Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
// Beneath, small: the abbreviation plus the event's own-zone time,
|
||||||
|
// e.g. "CET · 8:00 AM – 9:00 AM". formatWhen puts the range in the
|
||||||
|
// secondary half only for a same-day event; one spanning midnight
|
||||||
|
// — which a cross-zone event easily does — carries the whole span
|
||||||
|
// in the primary instead, so fall back to it. If the zone can't be
|
||||||
|
// read back at all, drop to just the abbreviation + offset.
|
||||||
|
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
|
||||||
|
?.let { formatWhen(instance, it, locale) }
|
||||||
|
?.let { (primary, secondary) -> secondary ?: primary }
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = if (originalTime != null) {
|
||||||
|
"${zoneOption.shortName} · $originalTime"
|
||||||
|
} else {
|
||||||
|
zoneDescriptor(zoneOption)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,21 +786,30 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
|||||||
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
|
||||||
* but only when the event is timed and pinned to a zone different from the
|
* pinned to a zone different from the device's — the cases where showing it
|
||||||
* device's. Returns null when there's nothing worth showing.
|
* removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
|
||||||
|
* tz database doesn't know).
|
||||||
|
*
|
||||||
|
* Resolved *at [at]*, the event's own start, not at "now": the abbreviation and
|
||||||
|
* offset both move with DST, and the card prints them next to the event's time.
|
||||||
|
* Resolving at now would label a July event "CET · 10:00 AM" when read in
|
||||||
|
* January — an abbreviation that contradicts the time beside it.
|
||||||
*/
|
*/
|
||||||
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
|
private fun foreignTimeZone(
|
||||||
|
tz: String?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
at: Instant,
|
||||||
|
): TimeZoneOption? {
|
||||||
if (isAllDay || tz.isNullOrBlank()) return null
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
val deviceZone = ZoneId.systemDefault().id
|
if (tz == ZoneId.systemDefault().id) return null
|
||||||
if (tz == deviceZone) return null
|
return timeZoneOptionOf(
|
||||||
return try {
|
tz,
|
||||||
val zone = ZoneId.of(tz)
|
locale,
|
||||||
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
at = at.toJavaInstant(),
|
||||||
if (name == tz) tz else "$name ($tz)"
|
regionOf = ::icuTimeZoneRegion,
|
||||||
} catch (e: Exception) {
|
)
|
||||||
tz
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -138,16 +141,48 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a create form seeded from the open event so the user can save a
|
||||||
|
* copy as a new, independent event (#52). Returns null when nothing is
|
||||||
|
* loaded.
|
||||||
|
*
|
||||||
|
* The recurrence is dropped — a duplicate is a single event; anyone who
|
||||||
|
* wants a series can re-add one in the edit form that opens. The source
|
||||||
|
* calendar is kept only when it's writable, otherwise the id is cleared so
|
||||||
|
* the create resolves to the last-used/first-writable calendar — which lets
|
||||||
|
* a read-only event (a WebCal subscription, a birthday) be copied into an
|
||||||
|
* editable calendar. The occurrence's own times carry over unchanged.
|
||||||
|
*/
|
||||||
|
fun duplicateForm(): EventForm? {
|
||||||
|
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||||
|
val detail = loaded.detail
|
||||||
|
return detail.toEditForm(
|
||||||
|
beginMillis = detail.instance.start.toEpochMilliseconds(),
|
||||||
|
endMillis = detail.instance.end.toEpochMilliseconds(),
|
||||||
|
zone = TimeZone.currentSystemDefault(),
|
||||||
|
).copy(
|
||||||
|
rrule = null,
|
||||||
|
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
val detail = repository.eventDetail(target.eventId)
|
val detail = repository.eventDetail(target.eventId)
|
||||||
// The Events row holds the series start; replace it with this
|
// The Events row holds the series start; replace it with this
|
||||||
// occurrence's time so recurring events render correctly.
|
// occurrence's time so recurring events render correctly. An external
|
||||||
val corrected = detail.copy(
|
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
|
||||||
|
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
|
||||||
|
// own DTSTART/DTEND instead of overriding it to the epoch.
|
||||||
|
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
|
||||||
|
detail
|
||||||
|
} else {
|
||||||
|
detail.copy(
|
||||||
instance = detail.instance.copy(
|
instance = detail.instance.copy(
|
||||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
val calendar = repository.calendars().first()
|
val calendar = repository.calendars().first()
|
||||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||||
EventDetailUiState.Success(
|
EventDetailUiState.Success(
|
||||||
@@ -168,6 +203,16 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Sentinel begin/end for an "open this event" that names no occurrence —
|
||||||
|
* a bare `content://com.android.calendar/events/<id>` VIEW intent with no
|
||||||
|
* `EXTRA_EVENT_BEGIN_TIME` (issue #48). [loadDetail] then keeps the event
|
||||||
|
* row's own DTSTART/DTEND instead of overriding it to the epoch.
|
||||||
|
*/
|
||||||
|
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ data class EventEditUiState(
|
|||||||
* neither list.
|
* neither list.
|
||||||
*/
|
*/
|
||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
|
/** Recently picked zones, most recent first — the zone picker's shortlist. */
|
||||||
|
val recentTimeZones: List<String> = emptyList(),
|
||||||
/** True while editing an existing event (the calendar is then fixed). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
|||||||
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.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
@@ -21,6 +22,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
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 de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -38,6 +40,8 @@ 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
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -50,6 +54,36 @@ import kotlin.time.Duration.Companion.hours
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
||||||
|
* sources want different reminder handling (#49).
|
||||||
|
*/
|
||||||
|
enum class ImportSource {
|
||||||
|
/**
|
||||||
|
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
||||||
|
* It carries no reminders of its own, so the settings default is applied
|
||||||
|
* automatically, exactly like an in-app new event.
|
||||||
|
*/
|
||||||
|
Insert,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
||||||
|
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
||||||
|
* rather than silently applied or suppressed.
|
||||||
|
*/
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pending offer to swap an imported `.ics` event's reminders for the settings
|
||||||
|
* default (#49). [currentReminderCount] is what the file carried (0 or more);
|
||||||
|
* [defaultReminders] is what accepting would set.
|
||||||
|
*/
|
||||||
|
data class ImportReminderPrompt(
|
||||||
|
val currentReminderCount: Int,
|
||||||
|
val defaultReminders: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the event form being composed. The form's calendar id resolves to
|
* Holds the event form being composed. The form's calendar id resolves to
|
||||||
* (user pick > last used > first writable); the resolved value is what the UI
|
* (user pick > last used > first writable); the resolved value is what the UI
|
||||||
@@ -78,10 +112,16 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
// their choice. Reset with the form.
|
// their choice. Reset with the form.
|
||||||
private val _remindersTouched = MutableStateFlow(false)
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||||
|
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||||
|
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
|
|
||||||
|
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
|
||||||
|
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event being edited plus everything the form saw at load time.
|
* The event being edited plus everything the form saw at load time.
|
||||||
* For recurring events the write scope is chosen at save time; the
|
* For recurring events the write scope is chosen at save time; the
|
||||||
@@ -112,7 +152,17 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val allDay: List<Int>,
|
val allDay: List<Int>,
|
||||||
val timedOverrides: Map<Long, List<Int>>,
|
val timedOverrides: Map<Long, List<Int>>,
|
||||||
val allDayOverrides: Map<Long, List<Int>>,
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
|
) {
|
||||||
|
/** The default reminders for an event on [calendarId] of the given kind. */
|
||||||
|
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
|
||||||
|
timedGlobal = timed,
|
||||||
|
allDayGlobal = allDay,
|
||||||
|
timedOverrides = timedOverrides,
|
||||||
|
allDayOverrides = allDayOverrides,
|
||||||
|
calendarId = calendarId,
|
||||||
|
isAllDay = isAllDay,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
@@ -172,7 +222,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
allCalendars,
|
allCalendars,
|
||||||
) { local, external, palette, allCalendars ->
|
settingsPrefs.recentTimeZones.flowOn(io),
|
||||||
|
) { local, external, palette, allCalendars, recentTimeZones ->
|
||||||
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 } }
|
||||||
@@ -188,14 +239,19 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val pickerCalendars =
|
val pickerCalendars =
|
||||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||||
else external.writable
|
else external.writable
|
||||||
val visibleFields = external.defaultFields + local.revealed
|
// An all-day event is date-anchored, so a zone is meaningless on it —
|
||||||
|
// the field is withheld from both lists rather than shown as a no-op.
|
||||||
|
val offerableFields = EventFormField.entries.toSet() -
|
||||||
|
if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet()
|
||||||
|
val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields
|
||||||
EventEditUiState(
|
EventEditUiState(
|
||||||
form = resolved,
|
form = resolved,
|
||||||
calendars = pickerCalendars,
|
calendars = pickerCalendars,
|
||||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
saveState = local.saveState,
|
saveState = local.saveState,
|
||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (offerableFields - visibleFields).sorted(),
|
||||||
|
recentTimeZones = recentTimeZones,
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
isManaged = isManaged,
|
isManaged = isManaged,
|
||||||
autofocusTitle = external.autofocusTitle,
|
autofocusTitle = external.autofocusTitle,
|
||||||
@@ -213,6 +269,13 @@ class EventEditViewModel @Inject constructor(
|
|||||||
initialValue = null,
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First day of the week for ordering the recurrence weekday toggles — the
|
||||||
|
* app's "week starts on" preference, matching the calendar views (Auto
|
||||||
|
* falls back to the locale convention).
|
||||||
|
*/
|
||||||
|
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||||
* from midnight) anchors the start when the form is opened by tapping a slot
|
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||||
@@ -241,18 +304,39 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
|
||||||
* the create form" path). [form] already carries the file's fields; its
|
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
|
||||||
* [EventForm.calendarId] is null so the calendar still resolves to the
|
* app/widget creating an event, e.g. Google Maps' "add to calendar";
|
||||||
* last-used/first-writable one, and reminders are frozen as touched so the
|
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
|
||||||
* settings default never overwrites what the file specified. No-op when a
|
* calendar still resolves to the last-used/first-writable one.
|
||||||
* form is already open, so the prefill survives configuration changes.
|
*
|
||||||
|
* Reminders are handled per [source], because the two paths mean different
|
||||||
|
* things by "no reminders":
|
||||||
|
* - [ImportSource.Insert] carries no reminder semantics, so the settings
|
||||||
|
* default is applied automatically like an in-app new event (a form that
|
||||||
|
* somehow did carry reminders keeps them, frozen).
|
||||||
|
* - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
|
||||||
|
* settings default is configured and differs, [importReminderPrompt] offers
|
||||||
|
* to swap it in rather than silently deciding for the user.
|
||||||
|
*
|
||||||
|
* No-op when a form is already open, so the prefill survives configuration
|
||||||
|
* changes.
|
||||||
*/
|
*/
|
||||||
fun openImported(form: EventForm) {
|
fun openImported(form: EventForm, source: ImportSource) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
_remindersTouched.value = true
|
|
||||||
_revealed.value = form.populatedFields()
|
_revealed.value = form.populatedFields()
|
||||||
_form.value = form
|
_form.value = form
|
||||||
|
when (source) {
|
||||||
|
ImportSource.Insert ->
|
||||||
|
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||||
|
else applyDefaultReminder()
|
||||||
|
|
||||||
|
ImportSource.File -> {
|
||||||
|
// Respect the file's own reminders; never silently overwrite them.
|
||||||
|
_remindersTouched.value = true
|
||||||
|
maybePromptImportedReminderDefault(form)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,26 +350,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
private fun applyDefaultReminder(calendarId: Long? = null) {
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val defaults = combine(
|
val defaults = reminderDefaults()
|
||||||
settingsPrefs.defaultReminderMinutes,
|
|
||||||
settingsPrefs.defaultAllDayReminderMinutes,
|
|
||||||
settingsPrefs.perCalendarReminderOverride,
|
|
||||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
|
||||||
) { timed, allDay, timedOv, allDayOv ->
|
|
||||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
|
||||||
}.first()
|
|
||||||
val targetId = calendarId ?: resolvedCalendarId.first()
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
// Re-check after suspending: bail if the form closed or the user edited.
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val reminders = resolveDefaultReminder(
|
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||||
timedGlobal = defaults.timed,
|
|
||||||
allDayGlobal = defaults.allDay,
|
|
||||||
timedOverrides = defaults.timedOverrides,
|
|
||||||
allDayOverrides = defaults.allDayOverrides,
|
|
||||||
calendarId = targetId,
|
|
||||||
isAllDay = form.isAllDay,
|
|
||||||
)
|
|
||||||
_form.value = form.copy(reminders = reminders)
|
_form.value = form.copy(reminders = reminders)
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
@@ -295,10 +365,60 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot the four settings-default reminder flows into one value. */
|
||||||
|
private suspend fun reminderDefaults(): ReminderDefaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||||
|
* file often has none while the user still expects their configured default.
|
||||||
|
* Rather than silently deciding, raise a one-time offer to swap in the
|
||||||
|
* settings default — but only when there's a real choice: a default is
|
||||||
|
* configured and it isn't already exactly what the file carried.
|
||||||
|
*/
|
||||||
|
private fun maybePromptImportedReminderDefault(form: EventForm) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val targetId = resolvedCalendarId.first()
|
||||||
|
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
||||||
|
// Bail if the form closed or became an edit while we resolved.
|
||||||
|
val current = _form.value ?: return@launch
|
||||||
|
if (_editTarget.value != null) return@launch
|
||||||
|
if (default.isEmpty() || default == current.reminders) return@launch
|
||||||
|
_importReminderPrompt.value = ImportReminderPrompt(
|
||||||
|
currentReminderCount = current.reminders.size,
|
||||||
|
defaultReminders = default,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accept the import prompt: replace the file's reminders with the default. */
|
||||||
|
fun applyImportedReminderDefault() {
|
||||||
|
val prompt = _importReminderPrompt.value ?: return
|
||||||
|
_importReminderPrompt.value = null
|
||||||
|
// Already frozen as touched by openImported; this just swaps the values.
|
||||||
|
update { it.copy(reminders = prompt.defaultReminders) }
|
||||||
|
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decline the import prompt: keep the file's own reminders untouched. */
|
||||||
|
fun dismissImportedReminderPrompt() {
|
||||||
|
_importReminderPrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||||
* tapped occurrence's own times, like on the detail screen. No-op while a
|
* tapped occurrence's own times, like on the detail screen. An external
|
||||||
* form is open, so user edits survive configuration changes.
|
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
|
||||||
|
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
|
||||||
|
* form loads the event's real times instead of the epoch (mirrors the
|
||||||
|
* detail screen's #48 fallback). No-op while a form is open, so user edits
|
||||||
|
* survive configuration changes.
|
||||||
*/
|
*/
|
||||||
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
@@ -312,8 +432,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = TimeZone.currentSystemDefault()
|
||||||
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||||
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
?: detail.instance.start.toEpochMilliseconds()
|
||||||
|
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||||
|
?: detail.instance.end.toEpochMilliseconds()
|
||||||
|
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
||||||
|
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
|
||||||
// Sections holding data must show even when not in the defaults.
|
// Sections holding data must show even when not in the defaults.
|
||||||
_revealed.value = snapshot.form.populatedFields()
|
_revealed.value = snapshot.form.populatedFields()
|
||||||
_form.value = snapshot.form
|
_form.value = snapshot.form
|
||||||
@@ -329,6 +453,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
_remindersTouched.value = false
|
_remindersTouched.value = false
|
||||||
|
_importReminderPrompt.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
@@ -336,16 +461,36 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_revealed.value = _revealed.value + field
|
_revealed.value = _revealed.value + field
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTitle(value: String) = update { it.copy(title = value) }
|
// The title field wraps (multi-line) so long titles stay visible (#33), but
|
||||||
|
// a title is one logical line: drop any newline the IME's Enter key or a
|
||||||
|
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||||
|
fun setTitle(value: String) =
|
||||||
|
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
||||||
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) {
|
fun setAllDay(value: Boolean) {
|
||||||
update { it.copy(isAllDay = value) }
|
// Going all-day drops any pinned zone: the times become bare dates that
|
||||||
|
// the provider stores UTC-anchored, so a zone could only misrepresent
|
||||||
|
// them. Coming back out of all-day leaves the event on the device zone,
|
||||||
|
// which is the same thing a fresh event gets.
|
||||||
|
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
|
||||||
// The default reminder differs for all-day vs timed; re-apply the
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the event to [zoneId], or pass null to follow the device. The zone
|
||||||
|
* rides along as a recent so the picker can offer it without a search next
|
||||||
|
* time — only real picks are remembered, not the device fallback.
|
||||||
|
*/
|
||||||
|
fun setTimezone(zoneId: String?) {
|
||||||
|
update { it.copy(timezone = zoneId) }
|
||||||
|
if (zoneId != null) {
|
||||||
|
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switching calendars drops any chosen colour: a palette key is
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
@@ -435,10 +580,15 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_saveState.value = SaveUiState.Saved
|
_saveState.value = SaveUiState.Saved
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Changing the calendar moves the event (copy+delete — CALENDAR_ID can't
|
||||||
|
// be updated in place) and is inherently whole-series: an occurrence
|
||||||
|
// can't live in a different calendar than its series, so a move skips the
|
||||||
|
// scope dialog even for a recurring event.
|
||||||
|
val movingCalendar = target != null && form.calendarId != target.original.calendarId
|
||||||
// Managed events are a yearly series whose editable fields (reminders,
|
// Managed events are a yearly series whose editable fields (reminders,
|
||||||
// notes, location) live on the series row — never offer the scope dialog,
|
// notes, location) live on the series row — never offer the scope dialog,
|
||||||
// which would split the series into an exception the sync then reverts.
|
// which would split the series into an exception the sync then reverts.
|
||||||
if (target != null && target.original.rrule != null && !current.isManaged) {
|
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
|
||||||
_saveState.value = SaveUiState.AwaitingScope
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -493,6 +643,17 @@ class EventEditViewModel @Inject constructor(
|
|||||||
if (target == null) {
|
if (target == null) {
|
||||||
repository.createEvent(form)
|
repository.createEvent(form)
|
||||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
|
} else if (form.calendarId != target.original.calendarId) {
|
||||||
|
// Move to the picked calendar (copy+delete), carrying any
|
||||||
|
// field edits made in the same save. The target becomes the
|
||||||
|
// last-used calendar, like a create does.
|
||||||
|
repository.moveEvent(
|
||||||
|
eventId = target.eventId,
|
||||||
|
targetCalendarId = requireNotNull(form.calendarId),
|
||||||
|
original = target.original,
|
||||||
|
updated = form,
|
||||||
|
)
|
||||||
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
} else {
|
} else {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringWriteScope.ThisEvent ->
|
RecurringWriteScope.ThisEvent ->
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ 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.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.imports
|
package de.jeanlucmakiola.calendula.ui.imports
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.background
|
||||||
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.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -18,6 +30,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -30,6 +43,12 @@ 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.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -38,14 +57,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles an opened/received `.ics` file. A single event is handed straight to
|
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||||
* the prefilled create form via [onOpenSingle]; several events show a target-
|
* the prefilled create form via [onOpenSingle]; several events show a target-
|
||||||
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
||||||
* Empty/failed files show a short message and close.
|
* Empty/failed files show a short message and close. [forceMany] keeps a
|
||||||
|
* single-event file on the bulk path — used by the in-app restore, whose intent
|
||||||
|
* is "restore a backup" rather than "add this one event".
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -53,9 +74,16 @@ fun ImportScreen(
|
|||||||
uri: Uri,
|
uri: Uri,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
viewModel: ImportViewModel = hiltViewModel(),
|
forceMany: Boolean = false,
|
||||||
|
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||||
|
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||||
|
// across imports — its one-shot `load` guard would then show the *previous*
|
||||||
|
// file's parsed state on the next import (a second restore, export→restore,
|
||||||
|
// etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading
|
||||||
|
// state), while the same uri (rotation) reuses it and holds the result.
|
||||||
|
viewModel: ImportViewModel = hiltViewModel(key = uri.toString()),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(uri) { viewModel.load(uri) }
|
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// A single event isn't shown here — it opens the create form for review.
|
// A single event isn't shown here — it opens the create form for review.
|
||||||
@@ -63,18 +91,55 @@ fun ImportScreen(
|
|||||||
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||||
|
// read it without the user scrolling to a bottom button. Defaults to the
|
||||||
|
// first *local* calendar — the first row the picker shows ("Your calendars"
|
||||||
|
// group leads) — so the pre-selection lines up with the top of the list;
|
||||||
|
// falls back to the first calendar if there are no local ones. Re-defaults
|
||||||
|
// when the "many" list first arrives (keyed on it), then holds the pick.
|
||||||
|
val many = state as? ImportUiState.Many
|
||||||
|
val defaultTarget = many?.calendars?.let { cals ->
|
||||||
|
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
|
||||||
|
}
|
||||||
|
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.predictiveBack(onBack = onClose)
|
.predictiveBack(onBack = onClose)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.import_title)) },
|
title = {
|
||||||
|
Text(
|
||||||
|
if (many != null) {
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_title_count,
|
||||||
|
many.events.size,
|
||||||
|
many.events.size,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.import_title)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onClose) {
|
IconButton(onClick = onClose) {
|
||||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
// Only meaningful in the multi-event picker with a writable
|
||||||
|
// target; every other state has nothing to confirm here.
|
||||||
|
if (many != null && many.calendars.isNotEmpty()) {
|
||||||
|
Button(
|
||||||
|
onClick = { selected?.let(viewModel::import) },
|
||||||
|
enabled = selected != null,
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.import_button))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
@@ -90,7 +155,7 @@ fun ImportScreen(
|
|||||||
|
|
||||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||||
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
|
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
|
||||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,84 +163,160 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
|
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
||||||
// No writable calendar to import into — tell the user honestly.
|
// No writable calendar to import into — tell the user honestly.
|
||||||
if (state.calendars.isEmpty()) {
|
if (state.calendars.isEmpty()) {
|
||||||
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(top = 8.dp, bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
CalendarPickerGroups(
|
||||||
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
|
calendars = state.calendars,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
selectedId = selected,
|
||||||
modifier = Modifier.padding(vertical = 8.dp),
|
onSelect = onSelect,
|
||||||
)
|
)
|
||||||
Text(
|
if (state.warnings.isNotEmpty()) {
|
||||||
stringResource(R.string.import_target_header),
|
Column(
|
||||||
style = MaterialTheme.typography.labelLarge,
|
Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
)
|
) {
|
||||||
state.calendars.forEach { calendar ->
|
|
||||||
OptionCard(
|
|
||||||
label = calendar.displayName,
|
|
||||||
onClick = { selected = calendar.id },
|
|
||||||
selected = calendar.id == selected,
|
|
||||||
icon = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
state.warnings.forEach { WarningText(it) }
|
state.warnings.forEach { WarningText(it) }
|
||||||
Button(
|
}
|
||||||
onClick = { onImport(selected) },
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||||
|
// A little expressive pop on the success badge — springs in on first show.
|
||||||
|
val badgeScale = remember { Animatable(0.7f) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
badgeScale.animateTo(
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessLow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(24.dp),
|
Modifier.fillMaxSize().padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(112.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = badgeScale.value
|
||||||
|
scaleY = badgeScale.value
|
||||||
|
}
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.import_done_title),
|
stringResource(R.string.import_done_title),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.padding(top = 24.dp),
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
pluralStringResource(
|
stringResource(R.string.import_done_dedup_note),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
ImportStatCard(
|
||||||
|
count = state.summary.imported,
|
||||||
|
label = stringResource(R.string.import_done_added_label),
|
||||||
|
contentDescription = pluralStringResource(
|
||||||
R.plurals.import_done_imported,
|
R.plurals.import_done_imported,
|
||||||
state.summary.imported,
|
state.summary.imported,
|
||||||
state.summary.imported,
|
state.summary.imported,
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
container = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
onContainer = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
)
|
)
|
||||||
if (state.summary.skippedDuplicate > 0) {
|
if (state.summary.skippedDuplicate > 0) {
|
||||||
Text(
|
ImportStatCard(
|
||||||
pluralStringResource(
|
count = state.summary.skippedDuplicate,
|
||||||
|
label = stringResource(R.string.import_done_skipped_label),
|
||||||
|
contentDescription = pluralStringResource(
|
||||||
R.plurals.import_done_skipped,
|
R.plurals.import_done_skipped,
|
||||||
state.summary.skippedDuplicate,
|
state.summary.skippedDuplicate,
|
||||||
state.summary.skippedDuplicate,
|
state.summary.skippedDuplicate,
|
||||||
),
|
),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Button(
|
||||||
|
onClick = onClose,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
Text(stringResource(R.string.import_close))
|
Text(stringResource(R.string.import_close))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A big-number tonal tile summarising one import outcome (added / skipped). */
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.ImportStatCard(
|
||||||
|
count: Int,
|
||||||
|
label: String,
|
||||||
|
contentDescription: String,
|
||||||
|
container: Color,
|
||||||
|
onContainer: Color,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clearAndSetSemantics { this.contentDescription = contentDescription },
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = container,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(vertical = 20.dp, horizontal = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
count.toString(),
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
color = onContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = onContainer.copy(alpha = 0.85f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WarningText(warning: IcsParseWarning) {
|
private fun WarningText(warning: IcsParseWarning) {
|
||||||
val text = when (warning) {
|
val text = when (warning) {
|
||||||
|
|||||||
@@ -66,8 +66,13 @@ class ImportViewModel @Inject constructor(
|
|||||||
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||||
private var started = false
|
private var started = false
|
||||||
|
|
||||||
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
|
/**
|
||||||
fun load(uri: Uri) {
|
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
|
||||||
|
* When [forceMany] is set (an in-app restore), a single-event file still goes
|
||||||
|
* through the bulk picker + summary rather than the prefilled create form —
|
||||||
|
* a restore is "bring back a backup", not "add this one event".
|
||||||
|
*/
|
||||||
|
fun load(uri: Uri, forceMany: Boolean = false) {
|
||||||
if (started) return
|
if (started) return
|
||||||
started = true
|
started = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -77,19 +82,21 @@ class ImportViewModel @Inject constructor(
|
|||||||
_state.value = when {
|
_state.value = when {
|
||||||
parsed == null -> ImportUiState.Failed
|
parsed == null -> ImportUiState.Failed
|
||||||
parsed.events.isEmpty() -> ImportUiState.Empty
|
parsed.events.isEmpty() -> ImportUiState.Empty
|
||||||
parsed.events.size == 1 -> ImportUiState.Single(
|
parsed.events.size == 1 && !forceMany -> ImportUiState.Single(
|
||||||
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// A disabled calendar is removed from the app, so it can't be
|
// A disabled calendar is removed from the app, so it can't be
|
||||||
// an import target — exclude it alongside the read-only ones.
|
// an import target — exclude it alongside the read-only ones.
|
||||||
|
// Managed special-dates calendars are contact-derived and
|
||||||
|
// editor-locked, so they're not a valid destination either.
|
||||||
val disabled = prefs.disabledCalendarIds.first()
|
val disabled = prefs.disabledCalendarIds.first()
|
||||||
ImportUiState.Many(
|
ImportUiState.Many(
|
||||||
events = parsed.events,
|
events = parsed.events,
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first()
|
calendars = repository.calendars().first()
|
||||||
.filter { it.canModifyContents && it.id !in disabled },
|
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What lets the split style's compact grid *become* the full month rather than be
|
||||||
|
* swapped for it (#53).
|
||||||
|
*
|
||||||
|
* The two grids are separate composables — the compact one draws dots, the
|
||||||
|
* expanded one draws the paged style's bars and pills — so nothing about them is
|
||||||
|
* shared by construction. Tagging the pieces that mean the same thing on both
|
||||||
|
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
|
||||||
|
* the other: a day's cell grows, its number rides along, and each dot travels out
|
||||||
|
* to the bar it stood for.
|
||||||
|
*
|
||||||
|
* It travels as a composition local rather than as parameters because the pieces
|
||||||
|
* sit five levels below where the scopes exist, and because a null default is
|
||||||
|
* exactly the right meaning for everyone else: the paged, continuous, and dense
|
||||||
|
* styles share the same row and cell composables, provide nothing, and pay
|
||||||
|
* nothing. Reduced motion provides nothing either, which leaves the plain
|
||||||
|
* cross-fade underneath.
|
||||||
|
*/
|
||||||
|
internal sealed interface MonthMorphKey {
|
||||||
|
/** A day's background pill — the structural anchor the rest rides on. */
|
||||||
|
data class Cell(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
data class DayNumber(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event *on one day*. Keyed by date as well as instance because a
|
||||||
|
* multi-day event has a dot on every day it covers but only one bar, drawn
|
||||||
|
* from where it starts: the start day's dot is the one that becomes the bar,
|
||||||
|
* and the rest are left unmatched on purpose. They fade where they stand
|
||||||
|
* while the bar sweeps out over them, which is the honest reading — one of
|
||||||
|
* them could not become the bar without the others teleporting into it.
|
||||||
|
*/
|
||||||
|
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day's "+N" marker. The compact grid writes it as a count beside the dots
|
||||||
|
* and the expanded one as a row of small dots under the bars, but they stand
|
||||||
|
* for the same events and appear on exactly the same days — both sides seat
|
||||||
|
* three and overflow the rest — so they are a matched pair, not two unrelated
|
||||||
|
* bits of text. Tagged, the marker travels with its day like everything else
|
||||||
|
* in the cell.
|
||||||
|
*/
|
||||||
|
data class Overflow(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected day's outline. The expanded grid draws no outline — once the
|
||||||
|
* cells carry real event bars it is one mark too many — so this pairs the
|
||||||
|
* compact grid's outline with an *invisible* stand-in over the same cell
|
||||||
|
* there. Without one it had nowhere to travel and could only fade in place
|
||||||
|
* while everything around it moved.
|
||||||
|
*/
|
||||||
|
data class Selection(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
|
||||||
|
data object Handle : MonthMorphKey
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
internal class MonthMorphScope(
|
||||||
|
val shared: SharedTransitionScope,
|
||||||
|
val visibility: AnimatedVisibilityScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the grid is mid-morph, for content that has to stop clipping to let
|
||||||
|
* the travelling pieces through.
|
||||||
|
*
|
||||||
|
* A mark's two homes are in *different rows*: the dot for the 20th sits a third
|
||||||
|
* of the way down the compact grid, its bar most of the way down the expanded
|
||||||
|
* one. Clipped to the row it is arriving at, a mark spends the first half of its
|
||||||
|
* journey outside those bounds and is simply not drawn — so it appears from
|
||||||
|
* nowhere, part-way through, already near its destination. Rendering in place
|
||||||
|
* rather than in an overlay is what subjects them to that clip, and is worth
|
||||||
|
* keeping; the clip is what has to yield, and only while pieces are in flight.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun morphInFlight(): Boolean {
|
||||||
|
val morph = LocalMonthMorph.current ?: return false
|
||||||
|
return morph.shared.isTransitionActive
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag content that is *the same thing* on both sides — a background pill, a day
|
||||||
|
* number — so it animates between its two positions and sizes.
|
||||||
|
*
|
||||||
|
* ### Why nothing renders in the overlay
|
||||||
|
*
|
||||||
|
* By default a travelling piece is painted into an overlay above the *entire*
|
||||||
|
* regular tree, so it can fly over anything in its way. That is right for a
|
||||||
|
* thumbnail crossing a screen, and wrong here: everything is moving inside one
|
||||||
|
* grid, and the overlay meant every untagged neighbour — the selection outline,
|
||||||
|
* the "+N" markers — spent the transition buried under pieces that had left the
|
||||||
|
* tree's z-order behind. Lifting each of them out in turn fixed the burying and
|
||||||
|
* bought a worse problem: they then floated over the grid on their own layer,
|
||||||
|
* out of step with it.
|
||||||
|
*
|
||||||
|
* Rendering in place puts everything back in one z-order and one clip, which is
|
||||||
|
* what makes the grid read as a single surface changing shape rather than a
|
||||||
|
* stack of pieces sliding past each other. Nothing here needs to escape its
|
||||||
|
* ancestors: the cells, marks and markers all travel within the grid.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
||||||
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphElement.sharedElement(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
renderInOverlayDuringTransition = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag content that means the same thing but *is drawn differently* on each side —
|
||||||
|
* a 5dp dot and a titled bar — so only the bounds are shared and the contents
|
||||||
|
* cross-fade inside them.
|
||||||
|
*
|
||||||
|
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
|
||||||
|
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
|
||||||
|
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
|
||||||
|
* simply clips it while there is no room, so what grows is the pill, not the type.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||||
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphBounds.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
|
renderInOverlayDuringTransition = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||||
|
* chooser.
|
||||||
|
*
|
||||||
|
* It renders the *real* grid composables rather than a drawing of them, so the
|
||||||
|
* preview cannot drift from the thing it depicts: change a cell's shape or an
|
||||||
|
* event bar's colour and every preview follows automatically. The trick is
|
||||||
|
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
|
||||||
|
* out at a full phone width and a plausible viewport height, and a
|
||||||
|
* [graphicsLayer] scale shrinks the finished layout into the card.
|
||||||
|
*
|
||||||
|
* The month, today's position and the week start are all real; only the events
|
||||||
|
* are stand-ins, since the settings screen has no business querying the
|
||||||
|
* provider for a thumbnail.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun MonthStylePreview(
|
||||||
|
style: MonthViewStyle,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
|
val scale = height.value / VIRTUAL_HEIGHT.value
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clipToBounds()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// A preview is a picture, not a control: swallow touches before the
|
||||||
|
// grid's own clickables see them, and give screen readers one label
|
||||||
|
// instead of six weeks of day cells.
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||||
|
.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
// Measure the grid at a full phone viewport but report the scaled
|
||||||
|
// size, so the node occupies exactly what it draws.
|
||||||
|
//
|
||||||
|
// Modifier.requiredSize would be the obvious way to force the larger
|
||||||
|
// measurement, but it *centres* content that overflows the incoming
|
||||||
|
// constraints — which pushed the grid to a negative offset and left
|
||||||
|
// only its bottom-right corner inside the clip.
|
||||||
|
.layout { measurable, _ ->
|
||||||
|
val fullWidth = screenWidth.roundToPx()
|
||||||
|
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
|
||||||
|
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||||
|
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||||
|
placeable.place(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||||
|
when (style) {
|
||||||
|
MonthViewStyle.Paged -> MonthGrid(
|
||||||
|
state = sample.month,
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||||
|
state = sample.continuous,
|
||||||
|
listState = rememberLazyListState(
|
||||||
|
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||||
|
monthIndexOf(YearMonth(today.year, today.month)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||||
|
state = sample.continuous,
|
||||||
|
listState = rememberLazyListState(
|
||||||
|
// Start a week above today's, so the preview shows a
|
||||||
|
// stream running past the viewport rather than one
|
||||||
|
// beginning at its top edge.
|
||||||
|
initialFirstVisibleItemIndex =
|
||||||
|
weekIndexOf(today, weekStart) - 1,
|
||||||
|
),
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Split -> {
|
||||||
|
SplitMonthGrid(
|
||||||
|
state = sample.month,
|
||||||
|
selected = today,
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onSelectDay = {},
|
||||||
|
)
|
||||||
|
SplitDayPane(
|
||||||
|
date = today,
|
||||||
|
today = today,
|
||||||
|
events = sample.month.instancesByDay[today].orEmpty(),
|
||||||
|
zone = zone,
|
||||||
|
onOpenDay = {},
|
||||||
|
onEventClick = {},
|
||||||
|
onCreateEvent = {},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The viewport the preview pretends to be: a phone's width by the height a
|
||||||
|
* calendar view gets under the top bar. Scaling from a fixed height keeps the
|
||||||
|
* three styles comparable — each shows the same slice of screen.
|
||||||
|
*/
|
||||||
|
private val VIRTUAL_HEIGHT = 440.dp
|
||||||
|
private val SPLIT_PANE_HEIGHT = 170.dp
|
||||||
|
|
||||||
|
private class SampleMonth(
|
||||||
|
val month: MonthUiState.Success,
|
||||||
|
val continuous: ContinuousMonthUiState.Success,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A month's worth of stand-in events, laid out through the same
|
||||||
|
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
||||||
|
* exercises the real span, lane and overflow logic rather than approximating it.
|
||||||
|
*
|
||||||
|
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||||
|
* event, so a token here would misrepresent what the grid actually renders.
|
||||||
|
*/
|
||||||
|
private fun sampleMonthState(
|
||||||
|
today: LocalDate,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
zone: TimeZone,
|
||||||
|
): SampleMonth {
|
||||||
|
val ym = YearMonth(today.year, today.month)
|
||||||
|
val first = LocalDate(ym.year, ym.month, 1)
|
||||||
|
val events = sampleEvents(first, today, zone)
|
||||||
|
|
||||||
|
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||||
|
val month = MonthUiState.Success(
|
||||||
|
month = ym,
|
||||||
|
today = today,
|
||||||
|
weeks = weeks,
|
||||||
|
instancesByDay = instancesByDay(weeks.flatMap { it.days }, events, zone),
|
||||||
|
zone = zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The month either side of this one: enough for Continuous to show a block,
|
||||||
|
// the whitespace under it and the next month's header coming up, and for
|
||||||
|
// Dense to have somewhere to scroll in both directions.
|
||||||
|
val centre = monthIndexOf(ym)
|
||||||
|
val window = (centre - 1)..(centre + 1)
|
||||||
|
val continuous = ContinuousMonthUiState.Success(
|
||||||
|
today = today,
|
||||||
|
monthsByIndex = window.associateWith { index ->
|
||||||
|
val m = yearMonthForIndex(index)
|
||||||
|
layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) }
|
||||||
|
},
|
||||||
|
weeksByIndex = weekWindowFor(window, weekStart).associateWith { index ->
|
||||||
|
val days = (0 until 7).map {
|
||||||
|
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
layoutCalendarWeek(days, events, zone)
|
||||||
|
},
|
||||||
|
weekStart = weekStart,
|
||||||
|
)
|
||||||
|
return SampleMonth(month, continuous)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SAMPLE_COLORS = listOf(
|
||||||
|
0xFF3F7BD4.toInt(),
|
||||||
|
0xFFCE5B4C.toInt(),
|
||||||
|
0xFF4E9A6A.toInt(),
|
||||||
|
0xFF8A63C7.toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun sampleEvents(
|
||||||
|
firstOfMonth: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<EventInstance> {
|
||||||
|
var id = 0L
|
||||||
|
fun next() = ++id
|
||||||
|
|
||||||
|
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
||||||
|
instanceId = next(),
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||||
|
start = day.atTime(hour, 0).toInstant(zone),
|
||||||
|
end = day.atTime(hour + length, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
||||||
|
instanceId = next(),
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||||
|
// All-day events sit at UTC midnights with an exclusive end.
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spread across the month so most weeks carry something, with one multi-day
|
||||||
|
// bar to show a span bridging cells and a busy today for the split pane.
|
||||||
|
return buildList {
|
||||||
|
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||||
|
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
||||||
|
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
||||||
|
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
||||||
|
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
||||||
|
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
||||||
|
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
||||||
|
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
||||||
|
// Today, so the split pane's list and the grid's dots both have content.
|
||||||
|
add(timed(today, 9, 1, 0))
|
||||||
|
add(timed(today, 12, 1, 1))
|
||||||
|
add(timed(today, 15, 2, 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SAMPLE_TITLES = listOf(
|
||||||
|
"Standup",
|
||||||
|
"Lunch",
|
||||||
|
"Review",
|
||||||
|
"Gym",
|
||||||
|
"Call",
|
||||||
|
"Workshop",
|
||||||
|
"Dentist",
|
||||||
|
"Trip",
|
||||||
|
)
|
||||||
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.month
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,12 +39,85 @@ data class MonthWeek(
|
|||||||
val countByDay: Map<LocalDate, Int>,
|
val countByDay: Map<LocalDate, Int>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||||
|
* order, capped at [laneCap] lanes.
|
||||||
|
*
|
||||||
|
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
|
||||||
|
* week: bars keep the lane the row layout gave them, and the day's timed events
|
||||||
|
* fill whatever slots are left, top-most first. Reading it here means the split
|
||||||
|
* style's dots and the paged style's bars describe a day in the *same order*, so
|
||||||
|
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
|
||||||
|
*
|
||||||
|
* Deriving the dots independently — by distinct colour, as they first were —
|
||||||
|
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||||
|
* that shared a calendar into a single dot.
|
||||||
|
*/
|
||||||
|
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||||
|
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||||
|
spans.forEach { span ->
|
||||||
|
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||||
|
byLane[span.lane] = span.event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||||
|
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||||
|
byLane[free[i]] = event
|
||||||
|
}
|
||||||
|
return byLane.filterNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||||
|
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||||
|
* absolute month index and carries only its own days — the boundary week keeps
|
||||||
|
* its seven columns so the grid geometry never shifts, but the neighbour month's
|
||||||
|
* dates are clipped out of it (see `clipWeekToMonth`) and render as blanks.
|
||||||
|
*
|
||||||
|
* The same loaded window is served two ways, because the Dense style is the same
|
||||||
|
* scroll with the seams taken out and it would be wasteful to query the provider
|
||||||
|
* twice for one range:
|
||||||
|
*
|
||||||
|
* - [monthsByIndex] — the block layout, keyed by absolute month index, each week
|
||||||
|
* clipped to its own month.
|
||||||
|
* - [weeksByIndex] — the flowing layout, keyed by absolute week index, boundary
|
||||||
|
* weeks left carrying both months.
|
||||||
|
*
|
||||||
|
* Both hold only the loaded window; indices outside it render as placeholders
|
||||||
|
* until the window catches up.
|
||||||
|
*/
|
||||||
|
sealed interface ContinuousMonthUiState {
|
||||||
|
data object Loading : ContinuousMonthUiState
|
||||||
|
data class Failure(val reason: FailureReason) : ContinuousMonthUiState
|
||||||
|
data class Success(
|
||||||
|
val today: LocalDate,
|
||||||
|
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
||||||
|
val weeksByIndex: Map<Int, MonthWeek>,
|
||||||
|
val weekStart: DayOfWeek,
|
||||||
|
) : ContinuousMonthUiState
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface MonthUiState {
|
sealed interface MonthUiState {
|
||||||
data object Loading : MonthUiState
|
data object Loading : MonthUiState
|
||||||
data class Failure(val reason: FailureReason) : MonthUiState
|
data class Failure(val reason: FailureReason) : MonthUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [weeks] is what the grid draws; [instancesByDay] is the same events keyed by
|
||||||
|
* date and *uncapped*, so the split style's day pane can list everything on a
|
||||||
|
* date without a second provider query — the month grid range already covers
|
||||||
|
* it. All-day events sort first, then by start.
|
||||||
|
*/
|
||||||
data class Success(
|
data class Success(
|
||||||
val month: YearMonth,
|
val month: YearMonth,
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val weeks: List<MonthWeek>,
|
val weeks: List<MonthWeek>,
|
||||||
|
val instancesByDay: Map<LocalDate, List<EventInstance>> = emptyMap(),
|
||||||
|
/**
|
||||||
|
* Travels on the state rather than being read at the call site, for the
|
||||||
|
* same reason the agenda does it: a file-level constant would be fixed
|
||||||
|
* for the process lifetime and drift from the zone the events were laid
|
||||||
|
* out in after a device time-zone change.
|
||||||
|
*/
|
||||||
|
val zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||||
) : MonthUiState
|
) : MonthUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -20,21 +20,23 @@ 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.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -48,16 +50,9 @@ class MonthViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
private val locale: Locale = Locale.getDefault()
|
|
||||||
|
|
||||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
.map { it.resolveFirstDay(locale) }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = DayOfWeek.MONDAY,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Whether to fade events that have already finished (display concern only). */
|
/** Whether to fade events that have already finished (display concern only). */
|
||||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||||
@@ -67,6 +62,22 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Whether to show the calendar-week number gutter (#25; display only). */
|
||||||
|
val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** How the grid is laid out and navigated (#38, #53). */
|
||||||
|
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = MonthViewStyle.Paged,
|
||||||
|
)
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
@@ -74,8 +85,12 @@ class MonthViewModel @Inject constructor(
|
|||||||
val month: StateFlow<YearMonth> = _month
|
val month: StateFlow<YearMonth> = _month
|
||||||
|
|
||||||
val state: StateFlow<MonthUiState> =
|
val state: StateFlow<MonthUiState> =
|
||||||
combine(_month, weekStart) { ym, ws -> ym to ws }
|
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
|
||||||
.flatMapLatest { (ym, ws) ->
|
.flatMapLatest { (ym, ws, style) ->
|
||||||
|
// The mirror of the gate below: the paged grid is what Paged and
|
||||||
|
// Split draw, so under a scrolling style this query is a month
|
||||||
|
// of provider work for a view that isn't on screen.
|
||||||
|
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
||||||
val range = monthGridRange(ym, ws, zone)
|
val range = monthGridRange(ym, ws, zone)
|
||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
@@ -92,21 +107,202 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = MonthUiState.Loading,
|
initialValue = MonthUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- Continuous + Dense styles (#38) ----------------------------------
|
||||||
|
//
|
||||||
|
// These scroll through every month there is, so they can't load "a month" —
|
||||||
|
// they load a sliding window of month indices around whatever is on screen.
|
||||||
|
// The window only moves when the visible range comes within WINDOW_EDGE
|
||||||
|
// months of a loaded edge, so a scroll re-queries occasionally rather than
|
||||||
|
// on every frame.
|
||||||
|
//
|
||||||
|
// The window also *starts small and grows*. A provider Instances query
|
||||||
|
// expands recurrences across its whole range, so opening straight onto a
|
||||||
|
// year of months made the first frame wait for eleven months of expansion
|
||||||
|
// when only one was about to be looked at. It now opens on INITIAL_PAD
|
||||||
|
// months, and each completed load widens by GROWTH_STEP in both directions
|
||||||
|
// until MAX_PAD — the months you can reach by scrolling arrive while you're
|
||||||
|
// still looking at the first one.
|
||||||
|
|
||||||
|
/** How far around the visible range we currently load; grows as loads land. */
|
||||||
|
@Volatile
|
||||||
|
private var loadPad = INITIAL_PAD
|
||||||
|
|
||||||
|
/** Last reported visible range, so a widening step can re-centre on it. */
|
||||||
|
@Volatile
|
||||||
|
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||||
|
.let { it..it }
|
||||||
|
|
||||||
|
private val _loadedMonths = MutableStateFlow(
|
||||||
|
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||||
|
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val continuousState: StateFlow<ContinuousMonthUiState> =
|
||||||
|
combine(_loadedMonths, weekStart, viewStyle) { window, ws, style ->
|
||||||
|
Triple(window, ws, style)
|
||||||
|
}
|
||||||
|
.flatMapLatest { (window, ws, style) ->
|
||||||
|
// Nothing to load for the paged and split styles — they have
|
||||||
|
// their own single-month flow, and querying a year of months
|
||||||
|
// behind them is pure waste.
|
||||||
|
if (!style.isScrolling) return@flatMapLatest flowOf(ContinuousMonthUiState.Loading)
|
||||||
|
// Widened to whole grid weeks at both ends: a month block still
|
||||||
|
// has to know about an event that starts in the boundary week's
|
||||||
|
// clipped-off days, or a bar running into the block would vanish.
|
||||||
|
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws)
|
||||||
|
val last = firstOfMonth(yearMonthForIndex(window.last))
|
||||||
|
.plus(1, DateTimeUnit.MONTH)
|
||||||
|
.minus(1, DateTimeUnit.DAY)
|
||||||
|
.startOfGridWeek(ws)
|
||||||
|
.plus(6, DateTimeUnit.DAY)
|
||||||
|
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(range),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildContinuousState(window, ws, style, calendars, instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A load landing is the cue to reach further out. Widening from here
|
||||||
|
// rather than on a timer means each step waits for the previous one,
|
||||||
|
// so the ladder can never outrun the provider.
|
||||||
|
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
|
||||||
|
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = ContinuousMonthUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report which absolute month indices are on screen. Cheap to call on every
|
||||||
|
* scroll frame: it returns immediately unless the visible range has drifted
|
||||||
|
* close enough to a loaded edge to warrant a wider query.
|
||||||
|
*/
|
||||||
|
fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) {
|
||||||
|
visibleMonths = firstIndex..lastIndex
|
||||||
|
nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex, loadPad)
|
||||||
|
?.let { _loadedMonths.value = clampMonthWindow(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rung up the ladder: reach [GROWTH_STEP] further in each direction,
|
||||||
|
* stopping at [MAX_PAD]. A no-op once there, so the provider notifications
|
||||||
|
* that re-emit the same window don't restart it.
|
||||||
|
*/
|
||||||
|
private fun widenLoadedWindow() {
|
||||||
|
if (loadPad >= MAX_PAD) return
|
||||||
|
loadPad = (loadPad + GROWTH_STEP).coerceAtMost(MAX_PAD)
|
||||||
|
_loadedMonths.value = clampMonthWindow(
|
||||||
|
visibleMonths.first - loadPad..visibleMonths.last + loadPad,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildContinuousState(
|
||||||
|
window: IntRange,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
style: MonthViewStyle,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
): ContinuousMonthUiState {
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
// Bucketed once for the whole window: a row then takes the handful of
|
||||||
|
// events on its seven days instead of re-scanning a year of them.
|
||||||
|
val byDay = DayIndex(instances, zone)
|
||||||
|
// Only the style on screen is laid out. Building both doubled the work
|
||||||
|
// for a layout nothing was going to draw.
|
||||||
|
val months = if (style == MonthViewStyle.Continuous) {
|
||||||
|
window.associateWith { index ->
|
||||||
|
val ym = yearMonthForIndex(index)
|
||||||
|
layoutMonthWeeks(ym, weekStart, byDay, zone).map { clipWeekToMonth(it, ym) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
val weeks = if (style == MonthViewStyle.Dense) {
|
||||||
|
weekWindowFor(window, weekStart).associateWith { index ->
|
||||||
|
val days = (0 until 7).map {
|
||||||
|
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
layoutCalendarWeek(days, byDay.eventsOn(days), zone)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
return ContinuousMonthUiState.Success(
|
||||||
|
today = todayDate,
|
||||||
|
monthsByIndex = months,
|
||||||
|
weeksByIndex = weeks,
|
||||||
|
weekStart = weekStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Split style (#53) ------------------------------------------------
|
||||||
|
//
|
||||||
|
// The first selected-day concept in the app: the other views drill straight
|
||||||
|
// into a date, while the split style keeps one selected and lists it below
|
||||||
|
// the grid.
|
||||||
|
|
||||||
|
private val _selectedDate = MutableStateFlow(todayDate)
|
||||||
|
val selectedDate: StateFlow<LocalDate> = _selectedDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select [date], following it to its month if it sits in the grid's leading
|
||||||
|
* or trailing days — tapping a greyed-out day should show that day, not
|
||||||
|
* silently list a date the grid isn't pointing at.
|
||||||
|
*/
|
||||||
|
fun selectDate(date: LocalDate) {
|
||||||
|
_selectedDate.value = date
|
||||||
|
val target = YearMonth(date.year, date.month)
|
||||||
|
if (target != _month.value) _month.value = target
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the selection with the month: today if the new month holds it, else
|
||||||
|
* its 1st. Leaving the old date selected would list a day the grid no longer
|
||||||
|
* shows.
|
||||||
|
*/
|
||||||
|
private fun realignSelection() {
|
||||||
|
_selectedDate.value = selectionForMonth(_month.value, todayDate)
|
||||||
|
}
|
||||||
|
|
||||||
fun goToPrev() {
|
fun goToPrev() {
|
||||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||||
|
realignSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToNext() {
|
fun goToNext() {
|
||||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||||
|
realignSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||||
|
_selectedDate.value = todayDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track [_month] to the month a scrolling style is showing. Those styles
|
||||||
|
* navigate by scroll position and never set [_month] themselves, so it would
|
||||||
|
* otherwise sit wherever paged navigation last left it (today's month, on a
|
||||||
|
* fresh open) — and a switch to a paged style, or a reseed of the other
|
||||||
|
* scrolling style's list, would jump there instead of holding position. The
|
||||||
|
* selection is realigned alongside it so that landing on the Split style
|
||||||
|
* shows a live day in the month on screen rather than a stale, off-month one.
|
||||||
|
*/
|
||||||
|
fun syncScrollMonth(ym: YearMonth) {
|
||||||
|
if (_month.value == ym) return
|
||||||
|
_month.value = ym
|
||||||
|
_selectedDate.value = selectionForMonth(ym, todayDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
fun goToDate(date: LocalDate) {
|
fun goToDate(date: LocalDate) {
|
||||||
_month.value = YearMonth(date.year, date.month)
|
_month.value = YearMonth(date.year, date.month)
|
||||||
|
_selectedDate.value = date
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
@@ -118,10 +314,13 @@ class MonthViewModel @Inject constructor(
|
|||||||
if (calendars.isEmpty()) {
|
if (calendars.isEmpty()) {
|
||||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
|
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
|
weeks = weeks,
|
||||||
|
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
|
||||||
|
zone = zone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,16 +339,83 @@ internal fun layoutMonthWeeks(
|
|||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
|
): List<MonthWeek> = layoutMonthWeeks(ym, weekStart, DayIndex(instances, zone), zone)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, but reusing a [DayIndex] already built for a wider range — what the
|
||||||
|
* scrolling styles need, since they lay out many months from one query and
|
||||||
|
* rebuilding the index per month would put the cost straight back.
|
||||||
|
*/
|
||||||
|
internal fun layoutMonthWeeks(
|
||||||
|
ym: YearMonth,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
byDay: DayIndex,
|
||||||
|
zone: TimeZone,
|
||||||
): List<MonthWeek> {
|
): List<MonthWeek> {
|
||||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
|
||||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
val weekCount = weekRowsInMonth(ym, weekStart)
|
||||||
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
|
||||||
val daysInMonth =
|
|
||||||
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
|
||||||
val weekCount = (leadOffset + daysInMonth + 6) / 7
|
|
||||||
|
|
||||||
return (0 until weekCount).map { row ->
|
return (0 until weekCount).map { row ->
|
||||||
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
||||||
|
layoutCalendarWeek(days, byDay.eventsOn(days), zone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events bucketed by the dates they cover, built once per load.
|
||||||
|
*
|
||||||
|
* [layoutCalendarWeek] opens by filtering the instances it was handed down to
|
||||||
|
* the ones touching its seven days. That is fine for a single month's grid, but
|
||||||
|
* the scrolling styles lay out a hundred-odd rows from one query, and scanning
|
||||||
|
* *every* instance in an eleven-month window for each of them made the work grow
|
||||||
|
* with the number of enabled calendars until opening the view stalled.
|
||||||
|
*
|
||||||
|
* Membership is decided by [coversDay] itself rather than by re-deriving the
|
||||||
|
* rule from the timestamps: the all-day and timed cases have enough edge cases
|
||||||
|
* between them (UTC anchoring, exclusive ends, zero-length events at midnight)
|
||||||
|
* that a second implementation would drift. The walk only visits an event's own
|
||||||
|
* candidate days, so the check runs a couple of times per event rather than once
|
||||||
|
* per event per row.
|
||||||
|
*/
|
||||||
|
internal class DayIndex(instances: List<EventInstance>, private val zone: TimeZone) {
|
||||||
|
|
||||||
|
private val byDay: Map<LocalDate, List<EventInstance>> = buildMap<LocalDate, MutableList<EventInstance>> {
|
||||||
|
instances.forEach { event ->
|
||||||
|
// All-day events are anchored to UTC midnights; timed ones to the
|
||||||
|
// device zone. Either way the candidate span is the event's own
|
||||||
|
// dates, which is one or two days for almost everything.
|
||||||
|
val anchor = if (event.isAllDay) TimeZone.UTC else zone
|
||||||
|
var day = event.start.toLocalDateTime(anchor).date
|
||||||
|
val lastCandidate = event.end.toLocalDateTime(anchor).date
|
||||||
|
while (day <= lastCandidate) {
|
||||||
|
if (event.coversDay(day, zone)) getOrPut(day) { mutableListOf() }.add(event)
|
||||||
|
day = day.plus(1, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every event touching any of [days], each listed once, in input order. */
|
||||||
|
fun eventsOn(days: List<LocalDate>): List<EventInstance> {
|
||||||
|
if (days.isEmpty()) return emptyList()
|
||||||
|
val hits = days.flatMap { byDay[it].orEmpty() }
|
||||||
|
// A multi-day event is bucketed on each date it covers, so a row that
|
||||||
|
// holds several of its days would otherwise list it several times.
|
||||||
|
return if (hits.size < 2) hits else hits.distinctBy { it.instanceId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one week row's events for rendering. Split out of [layoutMonthWeeks] so
|
||||||
|
* the continuous style — which streams weeks rather than months and so has no
|
||||||
|
* enclosing [YearMonth] to slice by — lays each row out identically.
|
||||||
|
*
|
||||||
|
* [days] must be the row's seven consecutive dates, in display order.
|
||||||
|
*/
|
||||||
|
internal fun layoutCalendarWeek(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): MonthWeek {
|
||||||
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||||
val (bars, singles) = weekEvents.partition { ev ->
|
val (bars, singles) = weekEvents.partition { ev ->
|
||||||
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
||||||
@@ -164,7 +430,7 @@ internal fun layoutMonthWeeks(
|
|||||||
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MonthWeek(
|
return MonthWeek(
|
||||||
days = days,
|
days = days,
|
||||||
spans = spans,
|
spans = spans,
|
||||||
timedByDay = days.associateWith { d ->
|
timedByDay = days.associateWith { d ->
|
||||||
@@ -172,9 +438,25 @@ internal fun layoutMonthWeeks(
|
|||||||
},
|
},
|
||||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every event touching each of [days], all-day first then by start time. Unlike
|
||||||
|
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
||||||
|
* they cover and applies no display cap, so the split style's day pane can list a
|
||||||
|
* date in full without querying the provider again.
|
||||||
|
*/
|
||||||
|
internal fun instancesByDay(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): Map<LocalDate, List<EventInstance>> =
|
||||||
|
days.associateWith { day ->
|
||||||
|
instances
|
||||||
|
.filter { it.coversDay(day, zone) }
|
||||||
|
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
||||||
* trailing days of the previous month and the leading days of the next month.
|
* trailing days of the previous month and the leading days of the next month.
|
||||||
@@ -192,6 +474,198 @@ internal fun monthGridRange(
|
|||||||
return start..end
|
return start..end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sliding window's shape.
|
||||||
|
*
|
||||||
|
* [INITIAL_PAD] is what the first frame waits for — one month either side of the
|
||||||
|
* visible one, so opening the view costs about what the paged style costs. Each
|
||||||
|
* completed load then reaches [GROWTH_STEP] further out until [MAX_PAD], filling
|
||||||
|
* in the months a scroll could reach while the first ones are already on screen.
|
||||||
|
*
|
||||||
|
* [WINDOW_EDGE] is how close the visible range may drift to a loaded edge before
|
||||||
|
* it reloads. It is always kept below the current pad — a trigger at or beyond
|
||||||
|
* the pad would re-fire the moment its own reload landed.
|
||||||
|
*/
|
||||||
|
private const val INITIAL_PAD = 1
|
||||||
|
private const val GROWTH_STEP = 2
|
||||||
|
private const val MAX_PAD = 5
|
||||||
|
private const val WINDOW_EDGE = 2
|
||||||
|
|
||||||
|
/** The reload trigger for a given pad, held strictly inside it. */
|
||||||
|
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which day the split style should select when the grid lands on [month]:
|
||||||
|
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
|
||||||
|
* tested without standing up a view model and a provider behind it.
|
||||||
|
*/
|
||||||
|
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||||
|
if (YearMonth(today.year, today.month) == month) {
|
||||||
|
today
|
||||||
|
} else {
|
||||||
|
LocalDate(month.year, month.month, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window to load for a visible range, or null to keep the current one.
|
||||||
|
*
|
||||||
|
* Kept pure and separate from the view model so the hysteresis — the reason a
|
||||||
|
* scroll doesn't re-query the provider on every frame — is testable on its own.
|
||||||
|
*/
|
||||||
|
internal fun nextLoadWindow(
|
||||||
|
loaded: IntRange,
|
||||||
|
firstVisible: Int,
|
||||||
|
lastVisible: Int,
|
||||||
|
pad: Int = MAX_PAD,
|
||||||
|
): IntRange? {
|
||||||
|
val edge = edgeForPad(pad)
|
||||||
|
val comfortablyInside =
|
||||||
|
firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last
|
||||||
|
if (comfortablyInside) return null
|
||||||
|
return (firstVisible - pad)..(lastVisible + pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The continuous grid addresses months by an absolute index, so the list has one
|
||||||
|
* stable, gap-free coordinate space to scroll through and key its items by.
|
||||||
|
* Index 0 is January 1900; the list runs to [continuousMonthCount].
|
||||||
|
*
|
||||||
|
* Unlike the week indexing this replaced, the coordinate space doesn't depend on
|
||||||
|
* the week-start preference — changing it reflows the rows *inside* a month
|
||||||
|
* block but never moves the block, so a scroll position stays put.
|
||||||
|
*/
|
||||||
|
private const val MONTH_INDEX_EPOCH_YEAR = 1900
|
||||||
|
private const val MONTH_INDEX_END_YEAR = 2100
|
||||||
|
|
||||||
|
internal fun monthIndexOf(ym: YearMonth): Int =
|
||||||
|
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
|
||||||
|
|
||||||
|
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
|
||||||
|
MONTH_INDEX_EPOCH_YEAR + index / 12,
|
||||||
|
Month.entries[index % 12],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Total months the continuous grid scrolls through (1900 → 2100). */
|
||||||
|
internal fun continuousMonthCount(): Int =
|
||||||
|
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each month occupies two LazyColumn items — its sticky header and the block of
|
||||||
|
* week rows under it — so the two coordinate spaces differ by a factor of two.
|
||||||
|
* Scrolling to a month means scrolling to its header.
|
||||||
|
*/
|
||||||
|
internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2
|
||||||
|
|
||||||
|
internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold a load window inside the months that actually exist. The pad
|
||||||
|
* [nextLoadWindow] adds can otherwise push an index past either end of the
|
||||||
|
* 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one.
|
||||||
|
*/
|
||||||
|
internal fun clampMonthWindow(window: IntRange): IntRange {
|
||||||
|
val last = continuousMonthCount() - 1
|
||||||
|
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dense style addresses *weeks* by absolute index instead: it has no month
|
||||||
|
* blocks to key by, and a row's identity has to survive the months around it
|
||||||
|
* scrolling past. Index 0 is the first week of 1900 under the active week-start,
|
||||||
|
* so — like the month space — every index is non-negative and the LazyColumn's
|
||||||
|
* item indices and week indices are the same number.
|
||||||
|
*
|
||||||
|
* Unlike month indices these *do* shift with the week-start preference, which is
|
||||||
|
* why the list state is keyed on it.
|
||||||
|
*/
|
||||||
|
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
||||||
|
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||||
|
|
||||||
|
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||||
|
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
|
||||||
|
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
||||||
|
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
|
||||||
|
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||||
|
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||||
|
|
||||||
|
/** Which month a Dense row belongs to, for reporting the visible range. */
|
||||||
|
internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
|
||||||
|
// The row's midweek day: a boundary week is reported as whichever month owns
|
||||||
|
// most of it, the same rule the title uses.
|
||||||
|
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
|
||||||
|
return monthIndexOf(YearMonth(midweek.year, midweek.month))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every week index the months in [window] touch — the Dense rows one month-window
|
||||||
|
* load covers. Widened by a week at each end so the boundary rows either side are
|
||||||
|
* laid out too, rather than flickering as placeholders.
|
||||||
|
*/
|
||||||
|
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
|
||||||
|
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1
|
||||||
|
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last))
|
||||||
|
.plus(1, DateTimeUnit.MONTH)
|
||||||
|
.minus(1, DateTimeUnit.DAY)
|
||||||
|
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
|
||||||
|
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
|
||||||
|
val first = firstOfMonth(ym)
|
||||||
|
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
||||||
|
return (leadOffset + daysInMonth + 6) / 7
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip everything outside [month] from a boundary week row, for the continuous
|
||||||
|
* style's self-contained month blocks.
|
||||||
|
*
|
||||||
|
* The seven [MonthWeek.days] stay put — the row keeps its column geometry, and
|
||||||
|
* the grid decides which cells to draw blank — but the neighbour month's events
|
||||||
|
* are removed: its pills and counts go, and a bar reaching in from (or out into)
|
||||||
|
* it is cut back to the month's own columns. A bar cut this way keeps a flat
|
||||||
|
* cap, which is what says "this continues past the block" rather than "it ends
|
||||||
|
* here".
|
||||||
|
*/
|
||||||
|
internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
|
||||||
|
val inMonth = week.days.map { it.year == month.year && it.month == month.month }
|
||||||
|
val firstCol = inMonth.indexOfFirst { it }
|
||||||
|
val lastCol = inMonth.indexOfLast { it }
|
||||||
|
// Can't happen for a row of the month's own grid, but a caller-proof no-op
|
||||||
|
// beats an out-of-range crash.
|
||||||
|
if (firstCol == -1) {
|
||||||
|
return week.copy(spans = emptyList(), timedByDay = emptyMap(), countByDay = emptyMap())
|
||||||
|
}
|
||||||
|
val spans = week.spans.mapNotNull { span ->
|
||||||
|
val start = maxOf(span.startCol, firstCol)
|
||||||
|
val end = minOf(span.endCol, lastCol)
|
||||||
|
if (start > end) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
span.copy(
|
||||||
|
startCol = start,
|
||||||
|
endCol = end,
|
||||||
|
continuesLeft = span.continuesLeft || start > span.startCol,
|
||||||
|
continuesRight = span.continuesRight || end < span.endCol,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val own = week.days.filterIndexed { col, _ -> inMonth[col] }
|
||||||
|
return week.copy(
|
||||||
|
spans = spans,
|
||||||
|
timedByDay = week.timedByDay.filterKeys { it in own },
|
||||||
|
countByDay = week.countByDay.filterKeys { it in own },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Month view lays itself out (#38, #53).
|
||||||
|
*
|
||||||
|
* One setting rather than two independent toggles: "vertical scrolling" and
|
||||||
|
* "month plus agenda" would otherwise multiply into four combinations to build
|
||||||
|
* and test, most of which nobody asked for.
|
||||||
|
*
|
||||||
|
* [Split] does **not** replace or disable the Agenda view — that stays a forward
|
||||||
|
* multi-day window with its own range model, while the split pane lists a single
|
||||||
|
* selected day.
|
||||||
|
*/
|
||||||
|
enum class MonthViewStyle {
|
||||||
|
/** Month pages, swiped left/right. Full event bars and pills per day. */
|
||||||
|
Paged,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Months stacked into one vertical scroll, each a self-contained block under
|
||||||
|
* its own sticky header: a block shows only its own days, and whitespace
|
||||||
|
* separates it from the next (#38).
|
||||||
|
*/
|
||||||
|
Continuous,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Continuous] with the seams taken out: one uninterrupted stream of weeks
|
||||||
|
* where months flow into each other. Each date appears exactly once — paging
|
||||||
|
* repeats a boundary week at both ends — and the 1st names its month, the
|
||||||
|
* only marker of where one ends.
|
||||||
|
*/
|
||||||
|
Dense,
|
||||||
|
|
||||||
|
/** Compact dots-only grid over a list of the selected day's events (#53). */
|
||||||
|
Split,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both vertically scrolling styles, which share a data window and a list state. */
|
||||||
|
val MonthViewStyle.isScrolling: Boolean
|
||||||
|
get() = this == MonthViewStyle.Continuous || this == MonthViewStyle.Dense
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val MonthViewStyle.labelRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
MonthViewStyle.Paged -> R.string.month_style_paged
|
||||||
|
MonthViewStyle.Continuous -> R.string.month_style_continuous
|
||||||
|
MonthViewStyle.Dense -> R.string.month_style_dense
|
||||||
|
MonthViewStyle.Split -> R.string.month_style_split
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val MonthViewStyle.descriptionRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
MonthViewStyle.Paged -> R.string.month_style_paged_summary
|
||||||
|
MonthViewStyle.Continuous -> R.string.month_style_continuous_summary
|
||||||
|
MonthViewStyle.Dense -> R.string.month_style_dense_summary
|
||||||
|
MonthViewStyle.Split -> R.string.month_style_split_summary
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
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.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
||||||
|
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
||||||
|
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
||||||
|
* A lock badge overlays the corner when permission has been [denied].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun BrandHero(denied: Boolean) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (denied) {
|
||||||
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
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.filled.Lock
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.res.colorResource
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
|
||||||
internal object OnboardingSpace {
|
|
||||||
val xs = 8.dp
|
|
||||||
val sm = 16.dp
|
|
||||||
val md = 24.dp
|
|
||||||
val lg = 32.dp
|
|
||||||
val xl = 48.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
|
|
||||||
* centred hero + body with the call(s) to action pinned to the bottom (clear
|
|
||||||
* of the navigation bar). The content slot is centred horizontally; benefit
|
|
||||||
* rows fill the width so their own content left-aligns.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun OnboardingScaffold(
|
|
||||||
hero: @Composable () -> Unit,
|
|
||||||
actions: @Composable ColumnScope.() -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
body: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
bottomBar = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
content = actions,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = OnboardingSpace.md),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
|
||||||
hero()
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
|
||||||
body()
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.md))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
|
||||||
@Composable
|
|
||||||
internal fun BrandHero(denied: Boolean) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
.clip(RoundedCornerShape(34.dp))
|
|
||||||
.background(colorResource(R.color.ic_launcher_background)),
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
|
||||||
contentDescription = stringResource(R.string.app_name),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (denied) {
|
|
||||||
// A small lock badge sits over the corner to signal "blocked".
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.offset(x = 10.dp, y = 10.dp)
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.errorContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
|
||||||
@Composable
|
|
||||||
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(OnboardingSpace.sm))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
text = body,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
|||||||
@@ -42,22 +42,24 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
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.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -101,6 +103,7 @@ fun SearchScreen(
|
|||||||
value = query,
|
value = query,
|
||||||
onValueChange = viewModel::setQuery,
|
onValueChange = viewModel::setQuery,
|
||||||
placeholder = stringResource(R.string.search_hint),
|
placeholder = stringResource(R.string.search_hint),
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
imeAction = ImeAction.Search,
|
imeAction = ImeAction.Search,
|
||||||
onImeAction = { keyboard?.hide() },
|
onImeAction = { keyboard?.hide() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -175,7 +178,7 @@ private fun SearchResults(
|
|||||||
SearchResultRow(
|
SearchResultRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, events.size),
|
position = positionOf(index, events.size),
|
||||||
modifier = calendarAnimateItem(),
|
modifier = animateItemMotion(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -190,6 +193,7 @@ private fun SearchResultRow(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
@@ -201,7 +205,7 @@ private fun SearchResultRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 6.dp, height = 36.dp)
|
.size(width = 6.dp, height = 36.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(pastelize(event.color, dark)),
|
.background(eventFill(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.core.os.LocaleListCompat
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import org.xmlpull.v1.XmlPullParser
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
|
||||||
*
|
|
||||||
* That file is the single source of truth for which languages we ship: dropping
|
|
||||||
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
|
||||||
* language show up here and in the system per-app-language settings, with no
|
|
||||||
* other code change. The system-default choice is represented as `null`.
|
|
||||||
*
|
|
||||||
* On API 33+ this delegates to the platform per-app-languages API; below that
|
|
||||||
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
|
||||||
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
|
||||||
* activity, which re-reads the current value for the picker.
|
|
||||||
*/
|
|
||||||
object AppLanguage {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The BCP-47 tags the app ships translations for, in declaration order, as
|
|
||||||
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
|
||||||
* or malformed config yields an empty list (the picker then offers only the
|
|
||||||
* system-default entry rather than crashing).
|
|
||||||
*/
|
|
||||||
fun supportedTags(context: Context): List<String> {
|
|
||||||
val tags = mutableListOf<String>()
|
|
||||||
val parser = context.resources.getXml(R.xml.locales_config)
|
|
||||||
try {
|
|
||||||
var event = parser.eventType
|
|
||||||
while (event != XmlPullParser.END_DOCUMENT) {
|
|
||||||
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
|
||||||
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
|
||||||
}
|
|
||||||
event = parser.next()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Fall back to whatever was parsed before the failure.
|
|
||||||
} finally {
|
|
||||||
parser.close()
|
|
||||||
}
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
|
||||||
fun currentTag(): String? {
|
|
||||||
val locales = AppCompatDelegate.getApplicationLocales()
|
|
||||||
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
|
||||||
fun apply(tag: String?) {
|
|
||||||
val locales = if (tag == null) {
|
|
||||||
LocaleListCompat.getEmptyLocaleList()
|
|
||||||
} else {
|
|
||||||
LocaleListCompat.forLanguageTags(tag)
|
|
||||||
}
|
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The autonym for a tag — the language's own name in its own script, e.g.
|
|
||||||
* "Deutsch", "English", "Français" — so users find their language regardless
|
|
||||||
* of the current UI language. Capitalised per the language's own rules.
|
|
||||||
*/
|
|
||||||
fun displayName(tag: String): String {
|
|
||||||
val locale = Locale.forLanguageTag(tag)
|
|
||||||
return locale.getDisplayName(locale)
|
|
||||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Month view style chooser (#38, #53).
|
||||||
|
*
|
||||||
|
* A live, scaled-down Month view sits above the options and changes as you pick
|
||||||
|
* one, so the choice is made by looking rather than by reading "Continuous" and
|
||||||
|
* guessing. Selecting therefore applies immediately and leaves the picker open —
|
||||||
|
* closing on tap would hide the very thing the screen is for. Back exits, the
|
||||||
|
* same as the App name picker, which stays open for the same reason.
|
||||||
|
*
|
||||||
|
* Under the preview sits the selected style's own one-line blurb, then the
|
||||||
|
* family's standard picker shape: connected single-line grouped rows with a
|
||||||
|
* tonal highlight and [SelectedCheck] on the current one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun MonthViewStylePicker(
|
||||||
|
selected: MonthViewStyle,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
onSelect: (MonthViewStyle) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val options = MonthViewStyle.entries
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_month_view_style),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
.height(PREVIEW_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
targetState = selected,
|
||||||
|
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||||
|
label = "month-style-preview",
|
||||||
|
) { shown ->
|
||||||
|
// The preview renders the real grid at phone size and scales it
|
||||||
|
// down, so its own corners are square — the frame rounds it.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(PREVIEW_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
MonthStylePreview(
|
||||||
|
style = shown,
|
||||||
|
weekStart = weekStart,
|
||||||
|
height = PREVIEW_HEIGHT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The selected style's own blurb lives here, under its preview, rather
|
||||||
|
// than on every row — the rows stay single-line and dense, and the words
|
||||||
|
// describe exactly what is being shown above.
|
||||||
|
PickerDescription(stringResource(selected.descriptionRes))
|
||||||
|
options.forEachIndexed { index, style ->
|
||||||
|
val isSelected = style == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(style.labelRes),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
// Applies straight away; the preview above is the confirmation,
|
||||||
|
// so there is nothing to dismiss for.
|
||||||
|
onClick = { onSelect(style) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PREVIEW_HEIGHT = 280.dp
|
||||||
|
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||||
@@ -23,10 +23,11 @@ import androidx.compose.animation.slideInHorizontally
|
|||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
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.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
|
||||||
@@ -43,8 +44,8 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.Cake
|
import androidx.compose.material.icons.filled.Cake
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
import androidx.compose.material.icons.filled.DragHandle
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
import androidx.compose.material.icons.filled.SwapVert
|
import androidx.compose.material.icons.filled.SwapVert
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
@@ -59,9 +60,9 @@ import androidx.compose.material.icons.filled.Translate
|
|||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.filled.UploadFile
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Surface
|
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
|
||||||
@@ -76,10 +77,12 @@ 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.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -93,46 +96,53 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
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.appname.LauncherName
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.AboutCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.AboutLink
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
|
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight
|
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
||||||
|
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||||
@@ -158,7 +168,7 @@ private enum class ChipAccent { Neutral, Primary, Tertiary }
|
|||||||
* Settings (M4), restructured in v2.3 into a category hub with sub-screens.
|
* 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
|
* Both the hub and the sub-screens use a collapsing [LargeTopAppBar] and the
|
||||||
* grouped-row card system. Calendars opens the separate manager hoisted in
|
* grouped-row card system. Calendars opens the separate manager hoisted in
|
||||||
* [CalendarHost]; Language opens an inline OptionCard dialog; About is a card
|
* [CalendarHost]; Language opens a full-screen picker; About is a card
|
||||||
* at the top. A full-screen destination; [onBack] pops it.
|
* at the top. A full-screen destination; [onBack] pops it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -236,7 +246,7 @@ private fun SettingsHub(
|
|||||||
onOpenSection: (SettingsSection) -> Unit,
|
onOpenSection: (SettingsSection) -> Unit,
|
||||||
onManageCalendars: () -> Unit,
|
onManageCalendars: () -> Unit,
|
||||||
) {
|
) {
|
||||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
|
||||||
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -357,7 +367,7 @@ private fun LanguageRow(position: Position) {
|
|||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
|
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
@@ -370,6 +380,7 @@ private fun LanguageRow(position: Position) {
|
|||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
|
predictiveBack = true,
|
||||||
options = options,
|
options = options,
|
||||||
selected = current,
|
selected = current,
|
||||||
label = { languageLabel(it) },
|
label = { languageLabel(it) },
|
||||||
@@ -396,83 +407,30 @@ private fun LanguageRow(position: Position) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutCard() {
|
private fun AboutCard() {
|
||||||
val context = LocalContext.current
|
// The card layout lives in floret-kit (components.AboutCard); Calendula
|
||||||
val sourceUrl = stringResource(R.string.about_source_url)
|
// supplies its own logo, author and the source / licence / support links.
|
||||||
val licenseUrl = stringResource(R.string.about_license_url)
|
AboutCard(
|
||||||
val supportUrl = stringResource(R.string.about_support_url)
|
logo = { AppLogo() },
|
||||||
|
appName = stringResource(R.string.app_name),
|
||||||
Surface(
|
author = stringResource(R.string.settings_about_author),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
primaryLinks = listOf(
|
||||||
shape = RoundedCornerShape(24.dp),
|
AboutLink(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
icon = ImageVector.vectorResource(R.drawable.ic_gitea),
|
||||||
) {
|
label = stringResource(R.string.settings_about_source),
|
||||||
Column(
|
url = stringResource(R.string.about_source_url),
|
||||||
modifier = Modifier
|
),
|
||||||
.fillMaxWidth()
|
AboutLink(
|
||||||
.padding(16.dp),
|
icon = Icons.Default.Gavel,
|
||||||
) {
|
label = stringResource(R.string.settings_license),
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
url = stringResource(R.string.about_license_url),
|
||||||
AppLogo()
|
),
|
||||||
Spacer(Modifier.width(16.dp))
|
),
|
||||||
Column(Modifier.weight(1f)) {
|
highlightLink = AboutLink(
|
||||||
Text(
|
icon = Icons.Default.Favorite,
|
||||||
text = stringResource(R.string.app_name),
|
label = stringResource(R.string.settings_about_support),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
url = stringResource(R.string.about_support_url),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.settings_about_author),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { openUrl(context, supportUrl) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Favorite,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.settings_about_support))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plain centred version mark at the foot of the settings list (no card). */
|
/** Plain centred version mark at the foot of the settings list (no card). */
|
||||||
@@ -536,8 +494,10 @@ private fun AppearanceScreen(
|
|||||||
var showPastEvents by remember { mutableStateOf(false) }
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
var showBrandFont by remember { mutableStateOf(false) }
|
var showBrandFont by remember { mutableStateOf(false) }
|
||||||
var showPlainFont by remember { mutableStateOf(false) }
|
var showPlainFont by remember { mutableStateOf(false) }
|
||||||
|
var showAppName by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||||
|
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
|
||||||
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||||
@@ -550,6 +510,7 @@ private fun AppearanceScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
// Theme & colour
|
// Theme & colour
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -565,7 +526,7 @@ private fun AppearanceScreen(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.settings_dynamic_color_unavailable)
|
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||||
},
|
},
|
||||||
position = Position.Bottom,
|
position = Position.Middle,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.dynamicColor,
|
checked = state.dynamicColor,
|
||||||
@@ -579,6 +540,18 @@ private fun AppearanceScreen(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_soften_colors),
|
||||||
|
summary = stringResource(R.string.settings_soften_colors_summary),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.softenColors,
|
||||||
|
onCheckedChange = viewModel::setSoftenColors,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setSoftenColors(!state.softenColors) },
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -607,12 +580,36 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showDefaultView = true },
|
onClick = { showDefaultView = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_today_toolbar),
|
||||||
|
summary = stringResource(R.string.settings_today_toolbar_summary),
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.todayButtonInToolbar,
|
||||||
|
onCheckedChange = viewModel::setTodayButtonInToolbar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
summary = weekStartLabel(state.weekStart),
|
summary = weekStartLabel(state.weekStart),
|
||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
onClick = { showWeekStart = true },
|
onClick = { showWeekStart = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_week_numbers),
|
||||||
|
summary = stringResource(R.string.settings_week_numbers_summary),
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.showWeekNumbers,
|
||||||
|
onCheckedChange = viewModel::setShowWeekNumbers,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_time_format),
|
title = stringResource(R.string.settings_time_format),
|
||||||
summary = timeFormatLabel(state.timeFormat),
|
summary = timeFormatLabel(state.timeFormat),
|
||||||
@@ -667,6 +664,18 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
onClick = { showAgendaWidgetRange = true },
|
onClick = { showAgendaWidgetRange = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_show_today),
|
||||||
|
summary = stringResource(R.string.settings_agenda_show_today_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.agendaShowToday,
|
||||||
|
onCheckedChange = viewModel::setAgendaShowToday,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_agenda_range_bar),
|
title = stringResource(R.string.settings_agenda_range_bar),
|
||||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||||
@@ -679,11 +688,62 @@ private fun AppearanceScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// App name — chooses the launcher label between "Calendula" and "Calendar"
|
||||||
|
// (issue #44). Own group: it's a launcher/system concern, not calendar
|
||||||
|
// formatting. A sub-page chooser (not a switch), matching the app's other
|
||||||
|
// "choose one" settings and leaving room for more names later.
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
summary = launcherNameLabel(launcherName),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showAppName = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showAppName) {
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
onDismiss = { showAppName = false },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
// Show both names as launcher-mark previews so the user sees what
|
||||||
|
// they'd switch to, not just the current state. Tapping applies
|
||||||
|
// immediately and highlights — the picker stays open so the change is
|
||||||
|
// visible; back exits.
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_app_name_summary),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
LauncherName.entries.forEach { option ->
|
||||||
|
AppNameOptionCard(
|
||||||
|
name = option,
|
||||||
|
selected = launcherName == option,
|
||||||
|
onClick = { viewModel.setLauncherName(option) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
|
predictiveBack = true,
|
||||||
options = ThemeMode.entries,
|
options = ThemeMode.entries,
|
||||||
selected = state.themeMode,
|
selected = state.themeMode,
|
||||||
label = { themeLabel(it) },
|
label = { themeLabel(it) },
|
||||||
@@ -716,6 +776,7 @@ private fun AppearanceScreen(
|
|||||||
if (showWeekStart) {
|
if (showWeekStart) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
|
predictiveBack = true,
|
||||||
options = WEEK_START_OPTIONS,
|
options = WEEK_START_OPTIONS,
|
||||||
selected = state.weekStart,
|
selected = state.weekStart,
|
||||||
label = { weekStartLabel(it) },
|
label = { weekStartLabel(it) },
|
||||||
@@ -744,6 +805,7 @@ private fun AppearanceScreen(
|
|||||||
if (showTimeFormat) {
|
if (showTimeFormat) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_time_format),
|
title = stringResource(R.string.settings_time_format),
|
||||||
|
predictiveBack = true,
|
||||||
options = TimeFormatPref.entries,
|
options = TimeFormatPref.entries,
|
||||||
selected = state.timeFormat,
|
selected = state.timeFormat,
|
||||||
label = { timeFormatLabel(it) },
|
label = { timeFormatLabel(it) },
|
||||||
@@ -764,6 +826,7 @@ private fun AppearanceScreen(
|
|||||||
if (showDefaultView) {
|
if (showDefaultView) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_default_view),
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
predictiveBack = true,
|
||||||
options = IMPLEMENTED_VIEWS,
|
options = IMPLEMENTED_VIEWS,
|
||||||
selected = state.defaultView,
|
selected = state.defaultView,
|
||||||
label = { stringResource(it.labelRes) },
|
label = { stringResource(it.labelRes) },
|
||||||
@@ -786,12 +849,24 @@ private fun ViewsScreen(
|
|||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var showMonthStyle by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_views),
|
title = stringResource(R.string.settings_section_views),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
) {
|
) {
|
||||||
val config = state.quickSwitchConfig
|
val config = state.quickSwitchConfig
|
||||||
|
|
||||||
|
// Per-view layout, above the cross-view switcher/order settings below.
|
||||||
|
SectionHeader(stringResource(R.string.settings_month_header))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_month_view_style),
|
||||||
|
summary = stringResource(state.monthViewStyle.labelRes),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showMonthStyle = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -837,6 +912,16 @@ private fun ViewsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showMonthStyle) {
|
||||||
|
MonthViewStylePicker(
|
||||||
|
selected = state.monthViewStyle,
|
||||||
|
// The preview is a real grid, so it uses the real week start too.
|
||||||
|
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||||
|
onSelect = viewModel::setMonthViewStyle,
|
||||||
|
onDismiss = { showMonthStyle = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
||||||
@@ -904,6 +989,7 @@ private fun EventFormScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_event_form),
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.settings_form_fields_hint),
|
text = stringResource(R.string.settings_form_fields_hint),
|
||||||
@@ -1039,6 +1125,7 @@ private fun NotificationsScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_notifications),
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_reminders),
|
title = stringResource(R.string.settings_reminders),
|
||||||
@@ -1087,13 +1174,7 @@ private fun NotificationsScreen(
|
|||||||
},
|
},
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
trailing = if (batteryExempt) {
|
trailing = if (batteryExempt) {
|
||||||
{
|
{ SelectedCheck() }
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -1129,8 +1210,8 @@ private fun NotificationsScreen(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = calendarSectionExpanded,
|
visible = calendarSectionExpanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
state.writableCalendars.forEach { calendar ->
|
state.writableCalendars.forEach { calendar ->
|
||||||
@@ -1178,18 +1259,18 @@ private fun NotificationsScreen(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = expanded,
|
visible = expanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||||
)
|
)
|
||||||
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
|
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_default_reminder_allday),
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||||
@@ -1205,9 +1286,9 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showSnooze) {
|
if (showSnooze) {
|
||||||
OptionPicker(
|
SnoozeDurationPicker(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
options = SNOOZE_PRESETS,
|
presets = SNOOZE_PRESETS,
|
||||||
selected = state.snoozeMinutes,
|
selected = state.snoozeMinutes,
|
||||||
label = { snoozeDurationLabel(it) },
|
label = { snoozeDurationLabel(it) },
|
||||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||||
@@ -1262,7 +1343,7 @@ private fun NotificationsScreen(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||||
selected = map.choiceFor(target.calendarId),
|
selected = map.reminderOverrideFor(target.calendarId),
|
||||||
allowInherit = true,
|
allowInherit = true,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
if (target.isAllDay) {
|
if (target.isAllDay) {
|
||||||
@@ -1451,7 +1532,7 @@ private fun SpecialDatesScreen(
|
|||||||
ReminderDefaultPicker(
|
ReminderDefaultPicker(
|
||||||
title = stringResource(R.string.settings_special_dates_reminders),
|
title = stringResource(R.string.settings_special_dates_reminders),
|
||||||
presets = ALLDAY_REMINDER_PRESETS,
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
selected = state.reminderChoices[type] ?: CalendarReminderOverride.None,
|
selected = state.reminderChoices[type] ?: ReminderOverride.None,
|
||||||
// Managed calendars own their reminders outright — no "inherit global".
|
// Managed calendars own their reminders outright — no "inherit global".
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
||||||
@@ -1461,8 +1542,8 @@ private fun SpecialDatesScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
||||||
private fun specialDatesReminderMinutes(choice: CalendarReminderOverride?): List<Int> =
|
private fun specialDatesReminderMinutes(choice: ReminderOverride?): List<Int> =
|
||||||
(choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
(choice as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SpecialDatesDisableDialog(
|
private fun SpecialDatesDisableDialog(
|
||||||
@@ -1561,12 +1642,12 @@ private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
|||||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||||
|
|
||||||
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||||
private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
|
private fun List<Int>.toReminderChoice(): ReminderOverride =
|
||||||
if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this)
|
||||||
|
|
||||||
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||||
private fun CalendarReminderOverride.toMinutesList(): List<Int> =
|
private fun ReminderOverride.toMinutesList(): List<Int> =
|
||||||
(this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
|
(this as? ReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
@@ -1650,13 +1731,13 @@ private fun reminderChoiceLabel(minutes: List<Int>): String {
|
|||||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun calendarOverrideSummary(
|
private fun calendarOverrideSummary(
|
||||||
choice: CalendarReminderOverride,
|
choice: ReminderOverride,
|
||||||
globalDefault: List<Int>,
|
globalDefault: List<Int>,
|
||||||
): String = when (choice) {
|
): String = when (choice) {
|
||||||
CalendarReminderOverride.Inherit ->
|
ReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1698,6 +1779,98 @@ private fun openUrl(context: Context, url: String) {
|
|||||||
runCatching { context.startActivity(intent) }
|
runCatching { context.startActivity(intent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The display name for a launcher-label choice (issue #44). */
|
||||||
|
@Composable
|
||||||
|
private fun launcherNameLabel(name: LauncherName): String = stringResource(
|
||||||
|
when (name) {
|
||||||
|
LauncherName.CALENDULA -> R.string.app_name
|
||||||
|
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable launcher-name preview in the App name picker (issue #44): the
|
||||||
|
* app's launcher mark over the name, framed as a card. The active one carries a
|
||||||
|
* primary border, a tinted container and a check; tapping selects it. The mark
|
||||||
|
* is the same for both — only the label changes — so the card previews exactly
|
||||||
|
* what the home screen will read.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AppNameOptionCard(
|
||||||
|
name: LauncherName,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
val borderColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
|
}
|
||||||
|
val containerColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(containerColor)
|
||||||
|
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 20.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// The adaptive launcher mark, reconstructed as a squircle (as in the
|
||||||
|
// onboarding BrandHero) so it renders identically everywhere.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = launcherNameLabel(name),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
// Selection indicator: a filled check when active, an empty ring otherwise.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||||
|
.then(
|
||||||
|
if (selected) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||||
when (mode) {
|
when (mode) {
|
||||||
@@ -1839,13 +2012,7 @@ private fun FontOptionRow(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailing = if (selected) {
|
trailing = if (selected) {
|
||||||
{
|
{ SelectedCheck() }
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -24,6 +25,8 @@ data class SettingsUiState(
|
|||||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
val dynamicColorAvailable: Boolean = true,
|
val dynamicColorAvailable: Boolean = true,
|
||||||
|
/** Whether raw provider colours are softened to theme-fitting pastels (#36). */
|
||||||
|
val softenColors: Boolean = true,
|
||||||
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||||
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||||
@@ -33,16 +36,24 @@ data class SettingsUiState(
|
|||||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||||
/** Whether the month/week grids fade events that have already finished. */
|
/** Whether the month/week grids fade events that have already finished. */
|
||||||
val dimCompletedEvents: Boolean = false,
|
val dimCompletedEvents: Boolean = false,
|
||||||
|
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
||||||
|
val showWeekNumbers: Boolean = false,
|
||||||
|
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
||||||
|
val todayButtonInToolbar: Boolean = false,
|
||||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||||
|
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
|
||||||
|
val agendaShowToday: Boolean = true,
|
||||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||||
val agendaShowRangeBar: Boolean = true,
|
val agendaShowRangeBar: Boolean = true,
|
||||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||||
|
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
||||||
|
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
@@ -96,7 +107,7 @@ data class SpecialDatesUiState(
|
|||||||
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
||||||
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||||
/** The reminder choice per type's managed calendar (applied to all its events). */
|
/** The reminder choice per type's managed calendar (applied to all its events). */
|
||||||
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
|
val reminderChoices: Map<SpecialDateType, ReminderOverride> = emptyMap(),
|
||||||
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
||||||
val showYear: Boolean = true,
|
val showYear: Boolean = true,
|
||||||
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherNameManager
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
@@ -17,8 +19,8 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||||
@@ -33,16 +35,20 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
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
|
||||||
@@ -61,6 +67,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||||
|
private val launcherNameManager: LauncherNameManager,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -116,9 +123,26 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.agendaScreenRange,
|
prefs.agendaScreenRange,
|
||||||
prefs.agendaWidgetRange,
|
prefs.agendaWidgetRange,
|
||||||
prefs.timeFormat,
|
prefs.timeFormat,
|
||||||
|
// Display toggles folded into one flow so they fit this group —
|
||||||
|
// the outer combine is already at its five-arg limit.
|
||||||
|
combine(
|
||||||
prefs.showHourLines,
|
prefs.showHourLines,
|
||||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
prefs.showWeekNumbers,
|
||||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
prefs.agendaShowToday,
|
||||||
|
prefs.softenCalendarColors,
|
||||||
|
prefs.todayButtonInToolbar,
|
||||||
|
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
|
||||||
|
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
|
||||||
|
},
|
||||||
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
|
ViewSettings(
|
||||||
|
view, screenRange, widgetRange, timeFormat,
|
||||||
|
showHourLines = toggles.showHourLines,
|
||||||
|
showWeekNumbers = toggles.showWeekNumbers,
|
||||||
|
agendaShowToday = toggles.agendaShowToday,
|
||||||
|
softenColors = toggles.softenColors,
|
||||||
|
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.agendaShowRangeBar,
|
prefs.agendaShowRangeBar,
|
||||||
@@ -127,8 +151,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.dimCompletedEvents,
|
prefs.dimCompletedEvents,
|
||||||
// View customisation (#24) folded into one flow so it fits this
|
// View customisation (#24) folded into one flow so it fits this
|
||||||
// group — the outer combine is already at its five-arg limit.
|
// group — the outer combine is already at its five-arg limit.
|
||||||
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
|
combine(
|
||||||
ViewCustomization(quickSwitch, drawer)
|
prefs.quickSwitchConfig,
|
||||||
|
prefs.drawerViewOrder,
|
||||||
|
prefs.monthViewStyle,
|
||||||
|
) { quickSwitch, drawer, monthStyle ->
|
||||||
|
ViewCustomization(quickSwitch, drawer, monthStyle)
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -140,12 +168,17 @@ class SettingsViewModel @Inject constructor(
|
|||||||
agendaWidgetRange = views.agendaWidgetRange,
|
agendaWidgetRange = views.agendaWidgetRange,
|
||||||
timeFormat = views.timeFormat,
|
timeFormat = views.timeFormat,
|
||||||
showHourLines = views.showHourLines,
|
showHourLines = views.showHourLines,
|
||||||
|
showWeekNumbers = views.showWeekNumbers,
|
||||||
|
agendaShowToday = views.agendaShowToday,
|
||||||
|
softenColors = views.softenColors,
|
||||||
|
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
dimCompletedEvents = misc.dimCompletedEvents,
|
dimCompletedEvents = misc.dimCompletedEvents,
|
||||||
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -180,6 +213,28 @@ class SettingsViewModel @Inject constructor(
|
|||||||
initialValue = AppFontSettings(),
|
initialValue = AppFontSettings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher-label choice (issue #44). Backed by the manifest aliases'
|
||||||
|
* component-enabled state rather than a stored preference, so it's read
|
||||||
|
* imperatively via [LauncherNameManager] and held here in its own flow — the
|
||||||
|
* main settings combine is already at its arity limit and this isn't a
|
||||||
|
* DataStore flow anyway.
|
||||||
|
*
|
||||||
|
* Seeded with the manifest default and corrected off-thread rather than read
|
||||||
|
* in the initializer: `getComponentEnabledSetting` is a binder round-trip to
|
||||||
|
* system_server, and this ViewModel is constructed on the main thread while
|
||||||
|
* Settings is opening. The row it feeds is well below the fold, so the
|
||||||
|
* one-frame correction is never visible.
|
||||||
|
*/
|
||||||
|
private val _launcherName = MutableStateFlow(LauncherName.CALENDULA)
|
||||||
|
val launcherName: StateFlow<LauncherName> = _launcherName.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_launcherName.value = withContext(io) { launcherNameManager.current() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Emitted when a picked font file couldn't be read as a font; the screen
|
// Emitted when a picked font file couldn't be read as a font; the screen
|
||||||
// surfaces it and the previous selection stays put.
|
// surfaces it and the previous selection stays put.
|
||||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
@@ -212,6 +267,18 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val agendaWidgetRange: AgendaRange,
|
val agendaWidgetRange: AgendaRange,
|
||||||
val timeFormat: TimeFormatPref,
|
val timeFormat: TimeFormatPref,
|
||||||
val showHourLines: Boolean,
|
val showHourLines: Boolean,
|
||||||
|
val showWeekNumbers: Boolean,
|
||||||
|
val agendaShowToday: Boolean,
|
||||||
|
val softenColors: Boolean,
|
||||||
|
val todayButtonInToolbar: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class DisplayToggles(
|
||||||
|
val showHourLines: Boolean,
|
||||||
|
val showWeekNumbers: Boolean,
|
||||||
|
val agendaShowToday: Boolean,
|
||||||
|
val softenColors: Boolean,
|
||||||
|
val todayButtonInToolbar: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -225,6 +292,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private data class ViewCustomization(
|
private data class ViewCustomization(
|
||||||
val quickSwitch: QuickSwitchConfig,
|
val quickSwitch: QuickSwitchConfig,
|
||||||
val drawerOrder: List<CalendarView>,
|
val drawerOrder: List<CalendarView>,
|
||||||
|
val monthViewStyle: MonthViewStyle,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||||
@@ -255,7 +323,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
base.copy(
|
base.copy(
|
||||||
// An override is always seeded on creation; present-empty = None.
|
// An override is always seeded on creation; present-empty = None.
|
||||||
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
||||||
allDayOverrides.choiceFor(calendarId)
|
allDayOverrides.reminderOverrideFor(calendarId)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
@@ -309,7 +377,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
* Set a type's managed-calendar reminder default and apply it to all its
|
* Set a type's managed-calendar reminder default and apply it to all its
|
||||||
* existing events (managed calendars own their reminders calendar-wide).
|
* existing events (managed calendars own their reminders calendar-wide).
|
||||||
*/
|
*/
|
||||||
fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
fun setSpecialDatesReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||||
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
|
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +392,19 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSoftenColors(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSoftenCalendarColors(enabled)
|
||||||
|
// Both widgets paint event colours through the same softener, so a
|
||||||
|
// change has to redraw them too (they read the flag in their data
|
||||||
|
// preamble, like is24Hour).
|
||||||
|
widgetRefreshMutex.withLock {
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
MonthWidget().updateAll(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setFont(role: FontRole, token: String) {
|
fun setFont(role: FontRole, token: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (role) {
|
when (role) {
|
||||||
@@ -390,6 +471,22 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAgendaShowToday(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setAgendaShowToday(enabled)
|
||||||
|
// The agenda widget reads this reactively, so push it into each
|
||||||
|
// instance's Glance state and recompose — same reliable-update path as
|
||||||
|
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
|
||||||
|
widgetRefreshMutex.withLock {
|
||||||
|
val manager = GlanceAppWidgetManager(appContext)
|
||||||
|
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||||
|
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
|
||||||
|
}
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setTimeFormat(pref: TimeFormatPref) {
|
fun setTimeFormat(pref: TimeFormatPref) {
|
||||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||||
}
|
}
|
||||||
@@ -398,6 +495,30 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setShowWeekNumbers(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTodayButtonInToolbar(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch the launcher label between "Calendula" and "Calendar" (issue #44).
|
||||||
|
* The card highlights immediately, then settles on whatever the component
|
||||||
|
* state actually reports — the two `setComponentEnabledSetting` calls are
|
||||||
|
* binder round-trips, so they don't belong on the main thread either.
|
||||||
|
*/
|
||||||
|
fun setLauncherName(name: LauncherName) {
|
||||||
|
_launcherName.value = name
|
||||||
|
viewModelScope.launch {
|
||||||
|
_launcherName.value = withContext(io) {
|
||||||
|
launcherNameManager.set(name)
|
||||||
|
launcherNameManager.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setPastEventDisplay(mode)
|
prefs.setPastEventDisplay(mode)
|
||||||
@@ -448,6 +569,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMonthViewStyle(style: MonthViewStyle) {
|
||||||
|
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
@@ -476,11 +601,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||||
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||||
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.theme
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
|
||||||
import androidx.compose.material3.MotionScheme
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App theme. Honors:
|
* Calendula's theme: the family's [FloretExpressiveTheme] machinery — dynamic
|
||||||
* - System light/dark.
|
* colour on API 31+, system light/dark, and the **standard** motion scheme
|
||||||
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
* (spring choreography without the overshoot) — fed with Calendula's own
|
||||||
* derived from [CalendulaSeed].
|
* identity: its seed-derived fallback schemes ([CalendulaLightFallback] /
|
||||||
* - A user-chosen [typography] (custom fonts, issue #19), defaulting to the
|
* [CalendulaDarkFallback]) and a [typography] that defaults to
|
||||||
* Material scale on the system typeface.
|
* [CalendulaTypography] but is overridable for the user's custom-font choice
|
||||||
|
* (issue #19).
|
||||||
*
|
*
|
||||||
* The Settings screen overrides darkTheme, dynamicColor, and typography; the
|
* The mechanics live in floret-kit's identity module; the look stays here, so a
|
||||||
* bare defaults (used by the crash screen) just follow the system.
|
* sibling app reuses the same theming without inheriting Calendula's palette.
|
||||||
|
* The Settings screen overrides darkTheme, dynamicColor and typography; the bare
|
||||||
|
* defaults (used by the crash screen) just follow the system.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendulaTheme(
|
fun CalendulaTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
@@ -30,24 +26,12 @@ fun CalendulaTheme(
|
|||||||
typography: Typography = CalendulaTypography,
|
typography: Typography = CalendulaTypography,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
FloretExpressiveTheme(
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
lightScheme = CalendulaLightFallback,
|
||||||
val ctx = LocalContext.current
|
darkScheme = CalendulaDarkFallback,
|
||||||
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
|
darkTheme = darkTheme,
|
||||||
}
|
dynamicColor = dynamicColor,
|
||||||
darkTheme -> CalendulaDarkFallback
|
|
||||||
else -> CalendulaLightFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaterialExpressiveTheme routes all component + custom motion through
|
|
||||||
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
|
|
||||||
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
|
|
||||||
// expressive(): same spring choreography, but without the overshoot —
|
|
||||||
// the bouncy variant felt overdone in review (2026-06-11).
|
|
||||||
MaterialExpressiveTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
typography = typography,
|
typography = typography,
|
||||||
motionScheme = MotionScheme.standard(),
|
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
|
|||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
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
|
||||||
@@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -77,20 +75,26 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
@@ -98,12 +102,13 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
@@ -112,6 +117,10 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
private val HOUR_HEIGHT = 56.dp
|
||||||
private val GUTTER_WIDTH = 48.dp
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||||
|
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
||||||
|
* (the app bar's 4dp inset + 24dp half icon button). */
|
||||||
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
private val MIN_EVENT_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
@@ -128,12 +137,14 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
|||||||
fun WeekScreen(
|
fun WeekScreen(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -171,6 +182,13 @@ fun WeekScreen(
|
|||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drives whether the title carries the year. Falls back to the clock only
|
||||||
|
// while the first load is in flight, when there is no state to read today from.
|
||||||
|
val currentYear = when (val s = state) {
|
||||||
|
is WeekUiState.Success -> s.today.year
|
||||||
|
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||||
|
}
|
||||||
|
|
||||||
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
@@ -219,16 +237,19 @@ fun WeekScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnCurrentWeek,
|
todayVisible = !isOnCurrentWeek && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.week_today_action),
|
todayText = stringResource(R.string.week_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = {
|
onCreate = {
|
||||||
@@ -249,6 +270,7 @@ fun WeekScreen(
|
|||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
@@ -268,12 +290,11 @@ private fun WeekContent(
|
|||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val threshold = with(density) { 24.dp.toPx() }
|
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -305,20 +326,7 @@ private fun WeekContent(
|
|||||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
||||||
// while a vertical drag is consumed by the inner scroll first — so the two
|
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||||
// gestures coexist without fighting.
|
// gestures coexist without fighting.
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { dragAccum = 0f },
|
|
||||||
onDragEnd = {
|
|
||||||
when {
|
|
||||||
dragAccum < -threshold -> onSwipeNext()
|
|
||||||
dragAccum > threshold -> onSwipePrev()
|
|
||||||
}
|
|
||||||
dragAccum = 0f
|
|
||||||
},
|
|
||||||
onDragCancel = { dragAccum = 0f },
|
|
||||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
@@ -342,6 +350,7 @@ private fun WeekContent(
|
|||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -355,6 +364,7 @@ private fun WeekSuccess(
|
|||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -363,7 +373,7 @@ private fun WeekSuccess(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(topSectionColor),
|
.background(topSectionColor),
|
||||||
) {
|
) {
|
||||||
WeekDayHeader(days = state.days, today = state.today)
|
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
||||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||||
}
|
}
|
||||||
// Breathing room between the (colour-shifting) top section and the
|
// Breathing room between the (colour-shifting) top section and the
|
||||||
@@ -382,16 +392,20 @@ private fun WeekSuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WeekTopBar(
|
private fun WeekTopBar(
|
||||||
weekStart: LocalDate,
|
weekStart: LocalDate,
|
||||||
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = formatWeekRange(weekStart),
|
text = formatWeekTitle(weekStart, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -404,6 +418,7 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -427,22 +442,24 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
private fun WeekDayHeader(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
today: LocalDate,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val weekStart = days.first()
|
val weekStart = days.first()
|
||||||
val weekNumber = remember(weekStart) {
|
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||||
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
|
|
||||||
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp, bottom = 8.dp),
|
.padding(top = 4.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||||
// badge lines up vertically with the date numbers.
|
// badge lines up vertically with the date numbers. The start inset centres
|
||||||
|
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.width(GUTTER_WIDTH),
|
modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||||
@@ -453,7 +470,10 @@ private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
|||||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||||
val isToday = date == today
|
val isToday = date == today
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable { onOpenDay(date) },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -573,9 +593,11 @@ private fun AllDayBar(
|
|||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
@@ -586,7 +608,7 @@ private fun AllDayBar(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.8f),
|
color = eventInk(fill),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -610,10 +632,12 @@ private fun Timeline(
|
|||||||
// soft corners are permanent at any scroll position (not just at the
|
// soft corners are permanent at any scroll position (not just at the
|
||||||
// day's start/end).
|
// day's start/end).
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
// Hour gutter (scrolls in sync with the day columns)
|
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||||
|
// as the header badge so the labels sit under it and on the hamburger.
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(GUTTER_WIDTH)
|
.width(GUTTER_WIDTH)
|
||||||
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
@@ -763,9 +787,11 @@ private fun EventBlock(
|
|||||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
@@ -776,7 +802,7 @@ private fun EventBlock(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = titleMaxLines,
|
maxLines = titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
)
|
)
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
Text(
|
||||||
@@ -784,7 +810,7 @@ private fun EventBlock(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.6f),
|
color = eventInk(fill, alpha = 0.6f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -830,18 +856,29 @@ private fun WeekLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
/**
|
||||||
val locale = Locale.getDefault()
|
* The week's title: just the month [weekStart] falls in.
|
||||||
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
*
|
||||||
val monthName = { d: LocalDate ->
|
* The day numbers are already in the column headers directly below, so spelling
|
||||||
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
|
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the
|
||||||
}
|
* month of [weekStart] means a week straddling a boundary keeps the outgoing
|
||||||
return if (weekStart.month == end.month && weekStart.year == end.year) {
|
* month until it is fully gone — a week is seven contiguous days, so the earlier
|
||||||
"${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}"
|
* month has a day in it exactly while [weekStart] is still inside it. That also
|
||||||
} else if (weekStart.year == end.year) {
|
* makes the *month* depend on nothing but [weekStart], so it cannot drift with
|
||||||
"${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}"
|
* the direction you paged in from.
|
||||||
} else {
|
*
|
||||||
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " +
|
* The *year* is decided from the whole week, not just its start: a week running
|
||||||
"${end.day}. ${monthName(end)} ${end.year}"
|
* Dec 28 – Jan 3 has four of its seven visible columns in the new year, so
|
||||||
}
|
* "December" with no year would be actively misleading while the reader is
|
||||||
|
* looking straight at January dates.
|
||||||
|
*/
|
||||||
|
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
|
||||||
|
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||||
|
return formatCalendarTitle(
|
||||||
|
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||||
|
locale = locale,
|
||||||
|
currentYear = currentYear,
|
||||||
|
skeleton = "LLLL",
|
||||||
|
forceYear = weekEnd.year != weekStart.year,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,12 +60,20 @@ sealed interface AgendaWidgetData {
|
|||||||
val days: List<AgendaDay>,
|
val days: List<AgendaDay>,
|
||||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||||
val is24Hour: Boolean,
|
val is24Hour: Boolean,
|
||||||
|
/** Whether event colours are softened to pastels, or shown raw (#36). */
|
||||||
|
val soften: Boolean,
|
||||||
/** First day of the week, for the calendar-aligned "this week" range. */
|
/** First day of the week, for the calendar-aligned "this week" range. */
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||||
val savedRange: AgendaRange,
|
val savedRange: AgendaRange,
|
||||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||||
val savedPastDisplay: PastEventDisplay,
|
val savedPastDisplay: PastEventDisplay,
|
||||||
|
/**
|
||||||
|
* Saved "always show today" pref (#35) — anchors today at the top with a
|
||||||
|
* placeholder even when it has no events left. The fallback read reactively
|
||||||
|
* from Glance state before an instance has its own state set.
|
||||||
|
*/
|
||||||
|
val savedShowToday: Boolean,
|
||||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||||
val now: Instant,
|
val now: Instant,
|
||||||
) : AgendaWidgetData
|
) : AgendaWidgetData
|
||||||
@@ -117,21 +125,26 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
val prefs = ep.settingsPrefs()
|
val prefs = ep.settingsPrefs()
|
||||||
val savedRange = prefs.agendaWidgetRange.first()
|
val savedRange = prefs.agendaWidgetRange.first()
|
||||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||||
|
val showToday = prefs.agendaShowToday.first()
|
||||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
// Load the widest selectable window once; the displayed range is sliced in
|
// Load the widest selectable window once; the displayed range is sliced in
|
||||||
// the composition from Glance state, so changing the range is a plain
|
// the composition from Glance state, so changing the range is a plain
|
||||||
// recomposition and never depends on the widget session restarting.
|
// recomposition and never depends on the widget session restarting.
|
||||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
||||||
|
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
|
||||||
val instances = ep.calendarRepository().instances(window).first()
|
val instances = ep.calendarRepository().instances(window).first()
|
||||||
val is24Hour = prefs.timeFormat.first()
|
val is24Hour = prefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||||
|
val soften = prefs.softenCalendarColors.first()
|
||||||
return AgendaWidgetData.Ready(
|
return AgendaWidgetData.Ready(
|
||||||
today = anchor,
|
today = anchor,
|
||||||
days = groupAgendaDays(anchor, instances, zone),
|
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
|
soften = soften,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
savedRange = savedRange,
|
savedRange = savedRange,
|
||||||
savedPastDisplay = savedPastDisplay,
|
savedPastDisplay = savedPastDisplay,
|
||||||
|
savedShowToday = showToday,
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size tiers a widget scales its typography and metrics across (#51).
|
||||||
|
*
|
||||||
|
* Shared by every Glance widget so the bucketing rule can't drift between them:
|
||||||
|
* each widget keeps its own metrics table, but they all agree on *when* a widget
|
||||||
|
* counts as compact, regular, large or extra-large. Both widgets already declare
|
||||||
|
* [androidx.glance.appwidget.SizeMode.Exact], so the composition sees the live
|
||||||
|
* size via `LocalSize.current` and passes it to [scaleFor].
|
||||||
|
*
|
||||||
|
* Kept in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing is
|
||||||
|
* covered by plain JVM tests.
|
||||||
|
*/
|
||||||
|
internal enum class WidgetScale { COMPACT, REGULAR, LARGE, XLARGE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome a widget spends before its first content row: outer vertical padding
|
||||||
|
* plus a header row and its spacer. Subtracted from the raw height so the height
|
||||||
|
* thresholds below talk about *usable* space rather than gross widget height.
|
||||||
|
*/
|
||||||
|
private val CHROME_HEIGHT = 60.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buckets a live widget size into a [WidgetScale] by **width**.
|
||||||
|
*
|
||||||
|
* Width is the right axis: it governs how much of a title fits on a row, so it's
|
||||||
|
* what should drive type size. Height only decides how many rows are visible — a
|
||||||
|
* tall, narrow widget wants *more events*, not bigger text — so it never raises
|
||||||
|
* the tier. It does act as a **cap**, though: a genuinely squashed widget is
|
||||||
|
* stepped back down so it can't keep oversized type in a sliver of space.
|
||||||
|
*
|
||||||
|
* The width thresholds are spread across the range a phone can actually produce
|
||||||
|
* (~180dp up to roughly the screen width) rather than over a theoretical range,
|
||||||
|
* so the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
|
||||||
|
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
|
||||||
|
* full-width 378dp one reaches LARGE. XLARGE is reserved for genuinely wide
|
||||||
|
* surfaces — tablets, foldables, landscape — where the extra size reads well.
|
||||||
|
*
|
||||||
|
* The height cap is deliberately generous: it exists to catch a widget squashed
|
||||||
|
* to one or two rows, **not** to gate ordinary placements. A full-width widget at
|
||||||
|
* the usual three cells tall (~270dp) must still reach the tier its width earned
|
||||||
|
* — that is exactly the resize #51 reports, and an aggressive cap would make the
|
||||||
|
* whole feature a no-op for it.
|
||||||
|
*/
|
||||||
|
internal fun scaleFor(size: DpSize): WidgetScale {
|
||||||
|
val byWidth = when {
|
||||||
|
size.width < 260.dp -> WidgetScale.COMPACT
|
||||||
|
size.width < 330.dp -> WidgetScale.REGULAR
|
||||||
|
size.width < 420.dp -> WidgetScale.LARGE
|
||||||
|
else -> WidgetScale.XLARGE
|
||||||
|
}
|
||||||
|
// Height can only ever pull the tier *down*, never push it up: a squashed
|
||||||
|
// widget would otherwise keep the big type its width earned and look absurd
|
||||||
|
// in the little space left. Keeping this a cap (rather than a second scaling
|
||||||
|
// axis) is what preserves "tall and narrow shows more events, not bigger
|
||||||
|
// text". Thresholds are usable height — roughly one, two and three rows of
|
||||||
|
// breathing room once the header is paid for.
|
||||||
|
val usable = size.height - CHROME_HEIGHT
|
||||||
|
val heightCap = when {
|
||||||
|
usable < 70.dp -> WidgetScale.COMPACT
|
||||||
|
usable < 130.dp -> WidgetScale.REGULAR
|
||||||
|
usable < 200.dp -> WidgetScale.LARGE
|
||||||
|
else -> WidgetScale.XLARGE
|
||||||
|
}
|
||||||
|
return minOf(byWidth, heightCap)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal layout constants for an agenda event row. These don't scale with the
|
||||||
|
* tier — a wider stripe or gap would eat title width, which is the thing the row
|
||||||
|
* is short of — but [TEXT_INDENT] is *derived* from them so the day header and
|
||||||
|
* the "nothing left today" line can never drift out of alignment with the event
|
||||||
|
* title column the way a hardcoded 19dp could.
|
||||||
|
*/
|
||||||
|
internal val ROW_H_PAD = 4.dp
|
||||||
|
internal val STRIPE_WIDTH = 5.dp
|
||||||
|
internal val STRIPE_GAP = 10.dp
|
||||||
|
|
||||||
|
/** Left indent that lines non-event text up with the event title column. */
|
||||||
|
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The width band a *default* agenda placement can land in, per
|
||||||
|
* `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`,
|
||||||
|
* `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but
|
||||||
|
* launcher cell grids vary, so the whole band — not one measured point — has to
|
||||||
|
* stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51
|
||||||
|
* to hold. A test pins that.
|
||||||
|
*
|
||||||
|
* If the provider's `targetCellWidth` ever changes, this band and the first
|
||||||
|
* width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be
|
||||||
|
* revisited together.
|
||||||
|
*/
|
||||||
|
internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every size the agenda widget varies by tier. Values that genuinely shouldn't
|
||||||
|
* grow (the horizontal row constants above, corner radii) stay constants rather
|
||||||
|
* than routing through here.
|
||||||
|
*/
|
||||||
|
internal data class AgendaMetrics(
|
||||||
|
val title: TextUnit, // header "Upcoming"
|
||||||
|
val dayHeader: TextUnit, // day label ("Today · …")
|
||||||
|
val eventTitle: TextUnit, // event title line
|
||||||
|
val eventTime: TextUnit, // time/location line
|
||||||
|
val placeholder: TextUnit, // "no more events today" (#35)
|
||||||
|
val message: TextUnit, // empty/permission centred message
|
||||||
|
val stripeH: Dp, // coloured event stripe height
|
||||||
|
val iconImage: Dp, // header action icon glyph
|
||||||
|
val iconBox: Dp, // header action touch target
|
||||||
|
val rowVPad: Dp, // event row vertical padding
|
||||||
|
val dayHeaderTopPad: Dp, // space above a day header
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Resolves the stripe height against the user's system font scale.
|
||||||
|
*
|
||||||
|
* The stripe is a [Dp] but the two text lines it sits beside are `sp`, so
|
||||||
|
* they scale with the accessibility font setting and the stripe does not —
|
||||||
|
* at "Largest" the text outgrows the stripe and it visibly under-runs the row
|
||||||
|
* it is supposed to mark. Multiplying by the same factor keeps them locked,
|
||||||
|
* and at the default scale of 1.0 reproduces the tier's value exactly.
|
||||||
|
*/
|
||||||
|
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
||||||
|
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Type sizes are anchored to the Material 3 type scale (see the `material-3`
|
||||||
|
* skill's typography reference) rather than invented: 16sp is Title Medium, 14sp
|
||||||
|
* Body Medium / Title Small, 12sp Body Small, 16sp Body Large, 22sp Title Large.
|
||||||
|
*
|
||||||
|
* Two documented deviations:
|
||||||
|
*
|
||||||
|
* ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately —
|
||||||
|
* COMPACT reproduces the widget's original constants verbatim so a
|
||||||
|
* default-sized widget looks exactly as it did (#51), and snapping it to
|
||||||
|
* Title Small (14sp) would break that promise for a 1sp gain.
|
||||||
|
*
|
||||||
|
* † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between,
|
||||||
|
* which is far too coarse for four widget tiers. Where a role would force a
|
||||||
|
* ≥1.4x step between adjacent tiers we hold an interpolated value instead and
|
||||||
|
* mark it. The endpoints stay on real roles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private val COMPACT_METRICS = AgendaMetrics(
|
||||||
|
title = 16.sp, // M3 Title Medium
|
||||||
|
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
||||||
|
eventTitle = 14.sp, // M3 Body Medium
|
||||||
|
eventTime = 12.sp, // M3 Body Small
|
||||||
|
placeholder = 14.sp, // M3 Body Medium
|
||||||
|
message = 14.sp, // M3 Body Medium
|
||||||
|
stripeH = 36.dp,
|
||||||
|
iconImage = 22.dp,
|
||||||
|
iconBox = 40.dp,
|
||||||
|
rowVPad = 4.dp,
|
||||||
|
dayHeaderTopPad = 10.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val REGULAR_METRICS = AgendaMetrics(
|
||||||
|
title = 18.sp, // †
|
||||||
|
dayHeader = 14.sp, // M3 Title Small
|
||||||
|
eventTitle = 16.sp, // M3 Body Large
|
||||||
|
eventTime = 14.sp, // M3 Body Medium
|
||||||
|
placeholder = 16.sp, // M3 Body Large
|
||||||
|
message = 16.sp, // M3 Body Large
|
||||||
|
stripeH = 40.dp,
|
||||||
|
iconImage = 24.dp,
|
||||||
|
iconBox = 44.dp,
|
||||||
|
rowVPad = 5.dp,
|
||||||
|
dayHeaderTopPad = 11.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LARGE_METRICS = AgendaMetrics(
|
||||||
|
title = 20.sp, // †
|
||||||
|
dayHeader = 16.sp, // M3 Title Medium
|
||||||
|
eventTitle = 18.sp, // †
|
||||||
|
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
|
||||||
|
placeholder = 18.sp, // † on purpose, so the title keeps its
|
||||||
|
message = 18.sp, // † lead and the hierarchy survives.
|
||||||
|
stripeH = 46.dp,
|
||||||
|
iconImage = 26.dp,
|
||||||
|
iconBox = 48.dp,
|
||||||
|
rowVPad = 6.dp,
|
||||||
|
dayHeaderTopPad = 12.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val XLARGE_METRICS = AgendaMetrics(
|
||||||
|
title = 22.sp, // M3 Title Large
|
||||||
|
dayHeader = 18.sp, // †
|
||||||
|
eventTitle = 20.sp, // †
|
||||||
|
eventTime = 16.sp, // M3 Body Large
|
||||||
|
placeholder = 20.sp, // †
|
||||||
|
message = 20.sp, // †
|
||||||
|
stripeH = 52.dp,
|
||||||
|
iconImage = 28.dp,
|
||||||
|
iconBox = 52.dp,
|
||||||
|
rowVPad = 8.dp,
|
||||||
|
dayHeaderTopPad = 14.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */
|
||||||
|
private val AGENDA_METRICS = listOf(
|
||||||
|
COMPACT_METRICS,
|
||||||
|
REGULAR_METRICS,
|
||||||
|
LARGE_METRICS,
|
||||||
|
XLARGE_METRICS,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal]
|
||||||
@@ -5,7 +5,7 @@ import android.content.res.Configuration
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
@@ -13,9 +13,11 @@ import androidx.glance.GlanceModifier
|
|||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
import androidx.glance.Image
|
import androidx.glance.Image
|
||||||
import androidx.glance.ImageProvider
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalSize
|
||||||
import androidx.glance.action.ActionParameters
|
import androidx.glance.action.ActionParameters
|
||||||
import androidx.glance.action.clickable
|
import androidx.glance.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
import androidx.glance.appwidget.action.ActionCallback
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
import androidx.glance.appwidget.action.actionRunCallback
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
@@ -48,14 +50,19 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
@@ -65,7 +72,6 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,10 +97,31 @@ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
|||||||
*/
|
*/
|
||||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-instance Glance state key holding the "always show today" toggle (#35).
|
||||||
|
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
|
||||||
|
* so toggling the setting reflects on the live widget without depending on the
|
||||||
|
* `provideGlance` preamble re-running.
|
||||||
|
*/
|
||||||
|
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
|
||||||
|
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
// Exact so the composition sees the widget's live size and can scale type/rows
|
||||||
|
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
|
||||||
|
// COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the
|
||||||
|
// same.
|
||||||
|
//
|
||||||
|
// Note Exact still asks Glance for one RemoteViews per host size (typically
|
||||||
|
// portrait + landscape) where the old SizeMode.Single produced exactly one —
|
||||||
|
// so the serialized payload roughly doubles. That is why the row list below is
|
||||||
|
// capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS
|
||||||
|
// = 365) could otherwise push the RemoteViews past the binder transaction
|
||||||
|
// limit and the host would just show "Problem loading widget".
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val data = context.loadAgendaWidgetData()
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
@@ -114,24 +141,41 @@ class RefreshAgendaAction : ActionCallback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||||
|
* stays well inside the binder transaction limit regardless of range and calendar
|
||||||
|
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
|
||||||
|
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||||
|
* risking a failed update for.
|
||||||
|
*/
|
||||||
|
private const val MAX_AGENDA_ROWS = 100
|
||||||
|
|
||||||
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||||
private sealed interface AgendaRow {
|
private sealed interface AgendaRow {
|
||||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||||
data class Event(val event: EventInstance) : AgendaRow
|
data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
|
||||||
|
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
||||||
|
data class Placeholder(val date: LocalDate) : AgendaRow
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||||
|
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a
|
||||||
|
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51).
|
||||||
|
// The stripe is then re-resolved against the system font scale so it tracks the
|
||||||
|
// sp-sized text beside it instead of drifting at large accessibility settings.
|
||||||
|
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
||||||
|
val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale)
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(GlanceTheme.colors.surface)
|
.background(GlanceTheme.colors.surface)
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
AgendaHeader()
|
AgendaHeader(metrics)
|
||||||
Spacer(GlanceModifier.height(4.dp))
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
when (data) {
|
when (data) {
|
||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||||
is AgendaWidgetData.Ready -> {
|
is AgendaWidgetData.Ready -> {
|
||||||
// Range read reactively from per-instance Glance state (falls back
|
// Range read reactively from per-instance Glance state (falls back
|
||||||
// to the saved pref for a freshly placed widget), then the wide
|
// to the saved pref for a freshly placed widget), then the wide
|
||||||
@@ -139,13 +183,17 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||||
val pastDisplay =
|
val pastDisplay =
|
||||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||||
|
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
||||||
val rangeEnd = data.today.plus(
|
val rangeEnd = data.today.plus(
|
||||||
range.dayCount(data.today, data.weekStart) - 1,
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
// Slice to the chosen range, then drop finished events (and any day
|
// Slice to the chosen range, then drop finished events (and any day
|
||||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||||
val visibleDays = data.days
|
// Finally re-anchor today (as an empty placeholder day) when the
|
||||||
|
// "always show today" pref is on and today has no events left (#35).
|
||||||
|
val visibleDays = anchorTodayIfMissing(
|
||||||
|
days = data.days
|
||||||
.filter { it.date <= rangeEnd }
|
.filter { it.date <= rangeEnd }
|
||||||
.let { days ->
|
.let { days ->
|
||||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
@@ -156,26 +204,41 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
} else {
|
} else {
|
||||||
days
|
days
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
today = data.today,
|
||||||
|
enabled = showToday,
|
||||||
|
)
|
||||||
if (visibleDays.isEmpty()) {
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title, metrics)
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
visibleDays.forEach { day ->
|
visibleDays.forEach { day ->
|
||||||
add(AgendaRow.Header(day.date, data.today))
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
if (day.events.isEmpty()) {
|
||||||
|
add(AgendaRow.Placeholder(day.date))
|
||||||
|
} else {
|
||||||
|
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Bound the payload, then drop a day header the cut left
|
||||||
|
// stranded with nothing under it.
|
||||||
|
.take(MAX_AGENDA_ROWS)
|
||||||
|
.dropLastWhile { it is AgendaRow.Header }
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||||
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||||
is AgendaRow.Event -> EventRow(
|
is AgendaRow.Event -> EventRow(
|
||||||
event = row.event,
|
event = row.event,
|
||||||
|
day = row.date,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
soften = data.soften,
|
||||||
is24Hour = data.is24Hour,
|
is24Hour = data.is24Hour,
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||||
row.event.hasEnded(data.now),
|
row.event.hasEnded(data.now),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +250,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaHeader() {
|
private fun AgendaHeader(metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
@@ -199,7 +262,7 @@ private fun AgendaHeader() {
|
|||||||
text = context.getString(R.string.widget_agenda_title),
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = GlanceTheme.colors.primary,
|
color = GlanceTheme.colors.primary,
|
||||||
fontSize = 16.sp,
|
fontSize = metrics.title,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
@@ -212,6 +275,7 @@ private fun AgendaHeader() {
|
|||||||
resId = R.drawable.ic_widget_refresh,
|
resId = R.drawable.ic_widget_refresh,
|
||||||
contentDescription = context.getString(R.string.widget_refresh),
|
contentDescription = context.getString(R.string.widget_refresh),
|
||||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
resId = R.drawable.ic_widget_add,
|
resId = R.drawable.ic_widget_add,
|
||||||
@@ -221,39 +285,77 @@ private fun AgendaHeader() {
|
|||||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
private fun IconButton(
|
||||||
|
resId: Int,
|
||||||
|
contentDescription: String,
|
||||||
|
onClick: GlanceModifier,
|
||||||
|
metrics: AgendaMetrics,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(resId),
|
provider = ImageProvider(resId),
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
modifier = GlanceModifier.size(22.dp),
|
modifier = GlanceModifier.size(metrics.iconImage),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Text(
|
Text(
|
||||||
text = agendaDayLabel(context, date, today),
|
text = agendaDayLabel(context, date, today),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = if (date == today) GlanceTheme.colors.primary
|
color = if (date == today) GlanceTheme.colors.primary
|
||||||
else GlanceTheme.colors.onSurfaceVariant,
|
else GlanceTheme.colors.onSurfaceVariant,
|
||||||
fontSize = 13.sp,
|
fontSize = metrics.dayHeader,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
.padding(
|
||||||
|
start = 8.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
top = metrics.dayHeaderTopPad,
|
||||||
|
bottom = metrics.rowVPad,
|
||||||
|
)
|
||||||
|
.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "No more events today" under an anchored, event-less today (#35). Kept plain —
|
||||||
|
* a muted line indented to the event titles — since the widget's event rows are
|
||||||
|
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.agenda_no_more_today),
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
start = TEXT_INDENT,
|
||||||
|
end = 8.dp,
|
||||||
|
top = 2.dp,
|
||||||
|
bottom = metrics.rowVPad + 2.dp,
|
||||||
|
)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||||
@@ -263,19 +365,27 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
private fun EventRow(
|
||||||
|
event: EventInstance,
|
||||||
|
day: LocalDate,
|
||||||
|
dark: Boolean,
|
||||||
|
soften: Boolean,
|
||||||
|
is24Hour: Boolean,
|
||||||
|
dimmed: Boolean,
|
||||||
|
metrics: AgendaMetrics,
|
||||||
|
) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||||
val stripeColor = pastelize(event.color, dark).let {
|
val stripeColor = eventFill(event.color, dark, soften).let {
|
||||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||||
}
|
}
|
||||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openEventIntent(
|
MainActivity.openEventIntent(
|
||||||
@@ -291,29 +401,29 @@ private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dim
|
|||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(5.dp)
|
.width(STRIPE_WIDTH)
|
||||||
.height(36.dp)
|
.height(metrics.stripeH)
|
||||||
.cornerRadius(3.dp)
|
.cornerRadius(3.dp)
|
||||||
.background(stripeColor),
|
.background(stripeColor),
|
||||||
) {}
|
) {}
|
||||||
Spacer(GlanceModifier.width(10.dp))
|
Spacer(GlanceModifier.width(STRIPE_GAP))
|
||||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, is24Hour),
|
text = eventTimeSummary(context, event, day, is24Hour),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WidgetMessage(resId: Int) {
|
private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
@@ -321,7 +431,7 @@ private fun WidgetMessage(resId: Int) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(resId),
|
text = context.getString(resId),
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,17 +447,26 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
|||||||
}
|
}
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
// Weekday + date in the locale's own field order (compact: no year), rather
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
// than a hardcoded day-month layout.
|
||||||
val formatted = "$weekday, ${date.day} $monthName"
|
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
|
||||||
return if (relative != null) "$relative · $formatted" else formatted
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
|
private fun eventTimeSummary(
|
||||||
val time = if (event.isAllDay) {
|
context: Context,
|
||||||
context.getString(R.string.event_detail_all_day)
|
event: EventInstance,
|
||||||
} else {
|
day: LocalDate,
|
||||||
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
is24Hour: Boolean,
|
||||||
|
): String {
|
||||||
|
val time = when (val label = agendaTimeLabel(event, day, zone())) {
|
||||||
|
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
|
||||||
|
is AgendaTimeLabel.Starts ->
|
||||||
|
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
|
||||||
|
is AgendaTimeLabel.Ends ->
|
||||||
|
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
|
||||||
|
is AgendaTimeLabel.Range ->
|
||||||
|
"${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}"
|
||||||
}
|
}
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
return if (location != null) "$time · $location" else time
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.widget.month
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
@@ -50,14 +49,18 @@ import de.jeanlucmakiola.calendula.MainActivity
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -76,9 +79,6 @@ private val LANE_HEIGHT = 14.dp
|
|||||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||||
private val GRID_HPADDING = 8.dp
|
private val GRID_HPADDING = 8.dp
|
||||||
|
|
||||||
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
|
|
||||||
private val EventInk = ColorProvider(Color(0xDE000000))
|
|
||||||
|
|
||||||
private fun currentMonthIndex(zone: TimeZone): Int {
|
private fun currentMonthIndex(zone: TimeZone): Int {
|
||||||
val t = today(zone)
|
val t = today(zone)
|
||||||
return t.year * 12 + t.month.ordinal
|
return t.year * 12 + t.month.ordinal
|
||||||
@@ -107,9 +107,12 @@ class MonthWidget : GlanceAppWidget() {
|
|||||||
val source = context.loadMonthWidgetSource()
|
val source = context.loadMonthWidgetSource()
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
// Read fresh (not through the cached source) so toggling the softener
|
||||||
|
// redraws with the new choice; it's one cheap DataStore read.
|
||||||
|
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
|
||||||
provideContent {
|
provideContent {
|
||||||
CalendulaGlanceTheme {
|
CalendulaGlanceTheme {
|
||||||
MonthWidgetBody(source = source, dark = dark)
|
MonthWidgetBody(source = source, dark = dark, soften = soften)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +143,7 @@ class ResetMonthAction : ActionCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -160,7 +163,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
|||||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||||
|
|
||||||
MonthHeader(label = monthLabel(ym))
|
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||||
weeks.forEach { week ->
|
weeks.forEach { week ->
|
||||||
@@ -169,6 +172,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
|||||||
currentMonth = ym.month,
|
currentMonth = ym.month,
|
||||||
today = source.today,
|
today = source.today,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
soften = soften,
|
||||||
colW = colW,
|
colW = colW,
|
||||||
modifier = GlanceModifier.defaultWeight(),
|
modifier = GlanceModifier.defaultWeight(),
|
||||||
)
|
)
|
||||||
@@ -278,6 +282,7 @@ private fun WeekRow(
|
|||||||
currentMonth: Month,
|
currentMonth: Month,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
soften: Boolean,
|
||||||
colW: Dp,
|
colW: Dp,
|
||||||
modifier: GlanceModifier,
|
modifier: GlanceModifier,
|
||||||
) {
|
) {
|
||||||
@@ -297,7 +302,7 @@ private fun WeekRow(
|
|||||||
// One lane row per event row. A multi-day span is a single Box spanning
|
// One lane row per event row. A multi-day span is a single Box spanning
|
||||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||||
repeat(MAX_LANES) { lane ->
|
repeat(MAX_LANES) { lane ->
|
||||||
LaneRow(week = week, lane = lane, dark = dark, colW = colW)
|
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||||
Spacer(GlanceModifier.height(1.dp))
|
Spacer(GlanceModifier.height(1.dp))
|
||||||
}
|
}
|
||||||
OverflowRow(week = week, colW = colW)
|
OverflowRow(week = week, colW = colW)
|
||||||
@@ -346,7 +351,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
var col = 0
|
var col = 0
|
||||||
@@ -354,12 +359,12 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||||
if (span != null) {
|
if (span != null) {
|
||||||
val cols = span.endCol - col + 1
|
val cols = span.endCol - col + 1
|
||||||
SpanBar(event = span.event, dark = dark, width = colW * cols)
|
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
|
||||||
col = span.endCol + 1
|
col = span.endCol + 1
|
||||||
} else {
|
} else {
|
||||||
val timed = timedEventAt(week, lane, col, week.days[col])
|
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||||
if (timed != null) {
|
if (timed != null) {
|
||||||
SpanBar(event = timed, dark = dark, width = colW)
|
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
|
||||||
} else {
|
} else {
|
||||||
// Empty lane cell: a tap opens that day, so blank space in a
|
// Empty lane cell: a tap opens that day, so blank space in a
|
||||||
// day column is a day-open target just like the number is.
|
// day column is a day-open target just like the number is.
|
||||||
@@ -378,8 +383,9 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
|
|
||||||
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(width)
|
.width(width)
|
||||||
@@ -402,13 +408,13 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
|||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.cornerRadius(4.dp)
|
.cornerRadius(4.dp)
|
||||||
.background(pastelize(event.color, dark)),
|
.background(fill),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = EventInk, fontSize = 9.sp),
|
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
|
||||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -475,9 +481,13 @@ private fun PermissionMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun monthLabel(month: YearMonth): String {
|
// Locale.getDefault() rather than currentLocale(): Glance has no
|
||||||
val locale = Locale.getDefault()
|
// LocalConfiguration, and the widget is re-rendered on a configuration change
|
||||||
val name = java.time.Month.of(month.month.ordinal + 1)
|
// anyway (same convention as AgendaWidget).
|
||||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
private fun monthLabel(month: YearMonth, currentYear: Int): String =
|
||||||
return "$name ${month.year}"
|
formatCalendarTitle(
|
||||||
}
|
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
|
||||||
|
locale = Locale.getDefault(),
|
||||||
|
currentYear = currentYear,
|
||||||
|
skeleton = "LLLL",
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||||
<!-- Permission-Flow (F1) -->
|
<!-- Permission-Flow (F1) -->
|
||||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
|
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr braucht sie nicht um zu funktionieren — und nichts verlässt dein Gerät.</string>
|
||||||
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||||
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
<string name="search_action">Suchen</string>
|
<string name="search_action">Suchen</string>
|
||||||
<string name="search_hint">Termine suchen</string>
|
<string name="search_hint">Termine suchen</string>
|
||||||
<string name="search_back">Zurück</string>
|
<string name="search_back">Zurück</string>
|
||||||
<string name="search_clear">Löschen</string>
|
<string name="search_clear">Leeren</string>
|
||||||
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
||||||
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
||||||
<!-- Startbildschirm-Widgets -->
|
<!-- Startbildschirm-Widgets -->
|
||||||
@@ -245,6 +245,7 @@
|
|||||||
<!-- Einstellungen (M4) -->
|
<!-- Einstellungen (M4) -->
|
||||||
<string name="settings_title">Einstellungen</string>
|
<string name="settings_title">Einstellungen</string>
|
||||||
<string name="settings_back">Zurück</string>
|
<string name="settings_back">Zurück</string>
|
||||||
|
<string name="back">Zurück</string>
|
||||||
<string name="settings_section_appearance">Darstellung</string>
|
<string name="settings_section_appearance">Darstellung</string>
|
||||||
<string name="settings_theme">Design</string>
|
<string name="settings_theme">Design</string>
|
||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
@@ -309,7 +310,7 @@
|
|||||||
<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>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Design, dynamische Farben, Wochenstart</string>
|
<string name="settings_appearance_subtitle">Design, Standardansicht, Wochenstart</string>
|
||||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||||
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
||||||
<string name="settings_section_about">Über</string>
|
<string name="settings_section_about">Über</string>
|
||||||
@@ -329,7 +330,7 @@
|
|||||||
<string name="calendars_add">Kalender hinzufügen</string>
|
<string name="calendars_add">Kalender hinzufügen</string>
|
||||||
<string name="calendars_synced_header">Synchronisierte Kalender</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_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_manage_in_app">In der App verwalten</string>
|
||||||
<string name="calendars_add_account">Konto hinzufügen</string>
|
<string name="calendars_add_account">Konto hinzufügen</string>
|
||||||
<string name="calendars_new_title">Neuer Kalender</string>
|
<string name="calendars_new_title">Neuer Kalender</string>
|
||||||
<string name="calendars_edit_title">Kalender bearbeiten</string>
|
<string name="calendars_edit_title">Kalender bearbeiten</string>
|
||||||
@@ -377,14 +378,116 @@
|
|||||||
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
|
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
||||||
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
|
<string name="crash_dialog_title">%1$s ist abgestürzt</string>
|
||||||
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
<string name="crash_dialog_message">%1$s wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
||||||
<string name="crash_dialog_report">Melden</string>
|
<string name="crash_dialog_report">Melden</string>
|
||||||
<string name="crash_dialog_dismiss">Nicht jetzt</string>
|
<string name="crash_dialog_dismiss">Nicht jetzt</string>
|
||||||
<string name="crash_report_issue_title">Absturzbericht</string>
|
<string name="crash_report_issue_title">Absturzbericht</string>
|
||||||
<string name="crash_report_clip_label">Calendula-Absturzbericht</string>
|
<string name="crash_report_clip_label">%1$s-Absturzbericht</string>
|
||||||
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
|
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
|
||||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
|
<string name="crash_report_body_template">Danke, dass du einen Absturz in %1$s meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%2$s\n</string>
|
||||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||||
|
<string name="event_edit_managed_hint">Verwaltet durch “%1$s” — der Titel, das Datum und Wiederholungen werden synchron mit deinen Kontakten gehalten. Erinnerungen, Ort und Beschreibung kannst du frei bearbeiten.</string>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d Tag</item>
|
||||||
|
<item quantity="other">%d Tage</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d Woche</item>
|
||||||
|
<item quantity="other">%d Wochen</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_default_view">Standardansicht</string>
|
||||||
|
<string name="settings_font_headings">Überschriften Schriftart</string>
|
||||||
|
<string name="settings_font_system">Systemstandard</string>
|
||||||
|
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||||
|
<string name="settings_font_choose_file">Datei auswählen…</string>
|
||||||
|
<string name="settings_font_import_failed">Die Datei konnte nicht als Schriftart gelesen werden</string>
|
||||||
|
<string name="settings_dim_completed">Vergangene Ereignisse abdunkeln</string>
|
||||||
|
<string name="settings_dim_completed_summary">Vergangene Termine in der Monats- und Wochenansicht abdunkeln</string>
|
||||||
|
<string name="settings_past_events">Vergangene Termine</string>
|
||||||
|
<string name="settings_past_events_show">Zeigen</string>
|
||||||
|
<string name="settings_past_events_dim">Abdunkeln</string>
|
||||||
|
<string name="settings_past_events_hide">Ausblenden</string>
|
||||||
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
|
<string name="settings_special_dates_reminders">Erinnerungen</string>
|
||||||
|
<string name="settings_font_body">Inhaltsschriftart</string>
|
||||||
|
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||||
|
<string name="font_lora">Lora</string>
|
||||||
|
<string name="settings_font_custom_selected">Benutzerdefinierte Schriftart</string>
|
||||||
|
<string name="settings_section_views">Ansichten</string>
|
||||||
|
<string name="settings_quick_switch_header">Schnellwechselknopf</string>
|
||||||
|
<string name="settings_quick_switch_hint">Wähle aus, durch welche Ansichten du mit dem Knopf oben rechts wechseln möchtest, und ziehe, um sie neu anzuordnen. Deaktivierte Ansichten bleiben über das Navigationsmenü erreichbar.</string>
|
||||||
|
<string name="settings_drawer_order_header">Navigationsmenü</string>
|
||||||
|
<string name="settings_drawer_order_hint">Ziehen, um die im Navigationsmenü aufgelisteten Ansichten neu anzuordnen.</string>
|
||||||
|
<string name="reorder_drag_handle">Zum Neuanordnen ziehen</string>
|
||||||
|
<string name="settings_calendar_reminders_title">Erinnerungen pro Kalender</string>
|
||||||
|
<string name="settings_translate">Hilf beim Übersetzen</string>
|
||||||
|
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
||||||
|
<string name="settings_views_subtitle">Schnellwechsellnopf und Menüreihenfolge</string>
|
||||||
|
<string name="settings_special_dates_subtitle">Kontakte-Geburtstage und Jubiläen</string>
|
||||||
|
<string name="settings_section_special_dates">Besondere Termine von Kontakten</string>
|
||||||
|
<string name="settings_special_dates_enable">Kontakttermine anzeigen</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">Spiegle die Geburtstage und andere Termine deiner Kontakte in lokale Kalender. Liest nur Kontakte auf diesem Gerät – es wird nichts hochgeladen und Ihre Kontakte werden nie geändert.</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">Geburtstage</string>
|
||||||
|
<string name="settings_special_dates_type_anniversary">Jahrestage</string>
|
||||||
|
<string name="settings_special_dates_type_custom">Andere Ereignisse</string>
|
||||||
|
<string name="settings_special_dates_template">Titelformat</string>
|
||||||
|
<string name="settings_special_dates_template_hint">Verwende {Name} für den Kontakt und {Jahr} für das Jahr (das Geburtsjahr oder das Anfangsjahr eines Jahrestages; versteckt, wenn unbekannt).</string>
|
||||||
|
<string name="settings_special_dates_show_year">Jahr anzeigen</string>
|
||||||
|
<string name="settings_special_dates_show_year_hint">{year} in Titel einfügen, wenn bekannt</string>
|
||||||
|
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
|
||||||
|
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
|
||||||
|
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
|
||||||
|
<string name="settings_special_dates_calendar_hint">Lege die Farbe und Sichtbarkeit jedes Kalenders in den Kalendereinstellungen fest.</string>
|
||||||
|
<string name="settings_special_dates_paused_title">Pausiert</string>
|
||||||
|
<string name="settings_calendar_reminders_managed_hint">In den besonderen Terminen der Kontakte setzen</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
|
||||||
|
<string name="settings_special_dates_disable_title">Kontakttermine deaktivieren?</string>
|
||||||
|
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontaktterminkalender und ihre Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
|
||||||
|
<string name="settings_special_dates_grant">Zugriff gewähren</string>
|
||||||
|
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
||||||
|
<string name="dialog_save">Speichern</string>
|
||||||
|
<string name="calendars_disable_hint">Deaktiviere einen Kalender, um ihn aus der App zu entfernen – seine Ereignisse, Filter und Auswahlmöglichkeiten. Es wird nichts gelöscht und du kannst ihn hier jederzeit wieder aktivieren.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">„%1$s“ in der App anzeigen</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Weitere Optionen für %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Alle aktivieren</string>
|
||||||
|
<string name="calendars_disable_all">Alle deaktivieren</string>
|
||||||
|
<string name="calendars_auto_backup">Automatische Sicherung</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Exportiere deine lokalen Kalender regelmäßig als .ics-Datei in einen Ordner.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Sicherungsordner</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Tippe, um einen Ordner auszuwählen</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Intervall</string>
|
||||||
|
<string name="calendars_auto_backup_every">Alle %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Mindestens 30 Minuten.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">Noch keine automatische Sicherung</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Letzte Sicherung: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Letzte Sicherung fehlgeschlagen: %1$s</string>
|
||||||
|
<string name="backup_channel_name">Sicherung</string>
|
||||||
|
<string name="backup_channel_description">Warnt, wenn automatische Sicherungen wiederholt fehlschlagen.</string>
|
||||||
|
<string name="backup_failed_title">Automatische Sicherung fehlgeschlagen</string>
|
||||||
|
<string name="backup_failed_text">Calendula konnte die Sicherungsdatei nicht schreiben. Überprüfe den Sicherungsordner in den Einstellungen.</string>
|
||||||
|
<string name="special_dates_calendar_birthday">Geburtstage</string>
|
||||||
|
<string name="special_dates_calendar_anniversary">Jahrestage</string>
|
||||||
|
<string name="special_dates_calendar_custom">Besondere Termine</string>
|
||||||
|
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
|
||||||
|
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
|
<string name="settings_week_numbers">Kalenderwochen</string>
|
||||||
|
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
|
||||||
|
<string name="calendars_export_title">Kalender exportieren</string>
|
||||||
|
<string name="calendars_export_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
|
||||||
|
<string name="calendars_export_action">Exportieren</string>
|
||||||
|
<string name="calendars_restore_header">Wiederherstellen</string>
|
||||||
|
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
|
||||||
|
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
|
||||||
|
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</string>
|
||||||
|
<string name="import_done_added_label">Hinzugefügt</string>
|
||||||
|
<string name="import_done_skipped_label">Duplikate</string>
|
||||||
|
<string name="import_button">Importieren</string>
|
||||||
|
<plurals name="import_title_count">
|
||||||
|
<item quantity="one">%d Ereignis wird importiert</item>
|
||||||
|
<item quantity="other">%d Ereignisse werden importiert</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user