Compare commits
149 Commits
feat/per-c
...
23e9a5a35b
| Author | SHA1 | Date | |
|---|---|---|---|
| 23e9a5a35b | |||
| 38a35be0f0 | |||
| f9f0572ec5 | |||
| 0221972e6d | |||
| 86ea72d56f | |||
| 114db7939c | |||
| 4503847c0d | |||
| 60aa889c57 | |||
| 3c9767387b | |||
| 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 | |||
| 79ad70e0c0 | |||
| f168021f51 | |||
| a2b1e7792b | |||
| c6ce0830b1 | |||
| be13f97802 | |||
| f6d40de832 | |||
| fdee311658 | |||
| 1ff41153e9 | |||
| 49d04bcee0 | |||
| 5e470baf4e | |||
| c8a4f90404 | |||
| 0a89ff9d0e | |||
| 0718afd5a6 | |||
| 84e6402031 | |||
| 7fe59b36c3 | |||
| 8881559e67 | |||
| 9f7e93d5f8 | |||
| 3863e74857 | |||
| 44d056f31f | |||
| 5c513a1b19 | |||
| 037d05b171 | |||
| 86d4e11584 | |||
| 67a07a9346 | |||
| b8a420bdab | |||
| 442697d57c | |||
| 8e3a17fdc3 | |||
| e6dd627c85 | |||
|
|
5844539db2 | ||
|
|
2cc83d7714 | ||
|
|
dada1bbe72 | ||
|
|
e061cbe127 | ||
| 262b2b8273 | |||
| efca0c86c4 | |||
| 08cd95cfba | |||
| bfc9a0db39 | |||
| 08905990cc | |||
| b5895b190e | |||
| 37f0e22911 | |||
| 4e125e58d5 | |||
| 4c26450ad4 | |||
| 52816b327a | |||
| cbb241bc4d | |||
| e5aa90bdd6 | |||
| 580dc5a669 | |||
| b9e800e9fc | |||
| 40d70d9f2b | |||
| 14b19cd902 | |||
| 0fd579b19b | |||
| 030c3e6ce1 | |||
| e67960d352 | |||
| 22bbfef3e2 | |||
| 4da9abe05b | |||
| 4540faf2a0 | |||
| 3aecb19a0c | |||
| f7c0b8a957 | |||
| 9a8d9b2177 | |||
| 93ca38d974 | |||
| a38093cae3 | |||
|
|
67511ce9be | ||
|
|
2e90682874 | ||
|
|
2a4cb6a896 | ||
| 8d530c5681 | |||
| 11578a5588 | |||
| 405ff16233 | |||
| 69460bc35a | |||
|
|
bf10deaf5c | ||
|
|
b9e861af62 | ||
|
|
2eadda937b | ||
|
|
71b90fa184 | ||
|
|
4e9cf88bd4 | ||
|
|
b999202629 | ||
|
|
6f0e13a4a2 | ||
|
|
9ef7b33800 | ||
|
|
d04b431f2c | ||
|
|
dbeeadff80 | ||
|
|
f4a169ef5f | ||
|
|
604099b3f2 | ||
|
|
a3e6541512 | ||
| 25ff7ce7ca |
@@ -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
|
||||||
147
CHANGELOG.md
147
CHANGELOG.md
@@ -8,14 +8,140 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- 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]).
|
||||||
|
|
||||||
|
### 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]).
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||||
|
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||||
|
— a **huge thank you** to
|
||||||
|
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||||
|
translation and to
|
||||||
|
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||||
|
one! Pick your language under Settings → Language or in Android's per-app
|
||||||
|
language settings. Strings added in this very release may still show in
|
||||||
|
English until the translations catch up. Want Calendula in your language?
|
||||||
|
Translating happens entirely in the browser — every contribution is welcome.
|
||||||
|
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||||
|
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||||
|
birthdays, anniversaries and other dates into local "Birthdays",
|
||||||
|
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||||
|
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||||
|
reminders the usual way — new birthdays even start with a reminder a week before
|
||||||
|
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||||
|
This is the first feature to use the contacts permission: it is requested only
|
||||||
|
when you turn the feature on, everything stays on your device (Calendula has no
|
||||||
|
internet access), and your contacts are only ever read, never changed. Thanks
|
||||||
|
to @moonj for the suggestion ([#15]).
|
||||||
- Set more than one default reminder per calendar. A calendar's default
|
- Set more than one default reminder per calendar. A calendar's default
|
||||||
reminders — and the global defaults under Settings → Notifications — can now
|
reminders — and the global defaults under Settings → Notifications — can now
|
||||||
hold several lead times instead of just one, so new events can start with, say,
|
hold several lead times instead of just one, so new events can start with, say,
|
||||||
a reminder a week before *and* one on the day. The reminder pickers are now
|
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||||
multi-select; per-calendar overrides can still inherit the global default or
|
multi-select; per-calendar overrides can still inherit the global default or
|
||||||
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||||
|
- Widget headers now open the app. Tapping the month/year title on the month
|
||||||
|
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||||
|
the agenda widget opens it on your default view — so there's a one-tap way
|
||||||
|
back into the app that lands where you'd expect, instead of only through a day
|
||||||
|
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||||
|
date number — now opens that day, and the "today" button snaps the grid back
|
||||||
|
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||||
|
suggestions ([#18], [#20]).
|
||||||
|
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||||
|
and **Body font** pickers: keep the system default, choose a bundled face
|
||||||
|
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||||
|
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||||
|
role. Font size and colour stay with Android's accessibility scaling and the
|
||||||
|
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#19]).
|
||||||
|
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||||
|
lets you pick which views the top-right quick-switch button rotates between
|
||||||
|
and drag them into your preferred order; views you switch off stay reachable
|
||||||
|
from the navigation menu, which can be reordered the same way. Thanks to
|
||||||
|
@abrossimow for the suggestion ([#24]).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Month widget arrows and "today" button work again. On release builds the
|
||||||
|
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||||
|
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||||
|
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||||
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||||
have disabled (Settings → Calendars) are now suppressed instead of still
|
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||||
popping up — matching how a disabled calendar's events already stay hidden
|
popping up — matching how a disabled calendar's events already stay hidden
|
||||||
@@ -26,6 +152,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||||
lands on just that one event and leaves the rest of the series untouched
|
lands on just that one event and leaves the rest of the series untouched
|
||||||
([#16]).
|
([#16]).
|
||||||
|
- Every calendar can be picked when creating an event. The event editor's
|
||||||
|
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||||
|
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||||
|
scrollable list grouped by account, with each calendar's colour shown.
|
||||||
|
Thanks to @dschuermann for the report ([#29]).
|
||||||
|
|
||||||
## [2.12.0] — 2026-06-28
|
## [2.12.0] — 2026-06-28
|
||||||
|
|
||||||
@@ -752,5 +883,21 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||||
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||||
|
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
|
||||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||||
|
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||||
|
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||||
|
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||||
|
[#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
|
||||||
|
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||||
|
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||||
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
|
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||||
|
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||||
|
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
||||||
|
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
||||||
|
|||||||
@@ -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 = 21200
|
versionCode = 21400
|
||||||
versionName = "2.12.0"
|
versionName = "2.14.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)
|
||||||
|
|||||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -16,6 +16,18 @@
|
|||||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
-dontwarn androidx.room.paging.**
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# Glance runs an @Composable's `actionRunCallback<T>()` by persisting the
|
||||||
|
# callback's fully-qualified class name into the click PendingIntent, then
|
||||||
|
# reflectively instantiating it (Class.forName(name).newInstance()) when the tap
|
||||||
|
# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only
|
||||||
|
# ever referenced reflectively — get renamed or have their no-arg constructor
|
||||||
|
# stripped, so the lookup fails silently and the tap does nothing. In the month
|
||||||
|
# and agenda widgets that broke every run-callback control (the prev/next/today
|
||||||
|
# month arrows and the agenda refresh) in release builds while actionStartActivity
|
||||||
|
# taps, which ride a PendingIntent and need no reflection, kept working. Keep
|
||||||
|
# every ActionCallback's name and constructor intact.
|
||||||
|
-keep class * implements androidx.glance.appwidget.action.ActionCallback { <init>(...); }
|
||||||
|
|
||||||
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||||
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||||
# keep both its name and a no-arg constructor. Glance renders every widget
|
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!--
|
||||||
|
Optional and feature-gated: only the "Contact special dates" feature reads
|
||||||
|
contacts, and only after the user enables it and grants this at runtime
|
||||||
|
(never requested at startup). Everything stays offline — birthdays and
|
||||||
|
other contact dates are mirrored one-way into local calendars; contacts
|
||||||
|
are never written and nothing leaves the device (the app has no INTERNET
|
||||||
|
permission). See docs/design/contact-special-dates.md.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
<!--
|
<!--
|
||||||
Lets the "Reliable delivery" setting open the direct system dialog to
|
Lets the "Reliable delivery" setting open the direct system dialog to
|
||||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||||
@@ -103,6 +112,17 @@
|
|||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- 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). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.INSERT" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import dagger.hilt.android.EntryPointAccessors
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
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.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
|
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
|
||||||
@@ -22,9 +25,20 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,4 +58,21 @@ class CalendulaApp : Application() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-arm (or cancel) the daily special-dates reconcile from the saved
|
||||||
|
* settings on every launch — like [reconcileAutoBackup], this cancels
|
||||||
|
* orphaned work after the feature is turned off and re-schedules after a
|
||||||
|
* reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open
|
||||||
|
* refresh, so no immediate run is needed here.
|
||||||
|
*/
|
||||||
|
private fun reconcileSpecialDates() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
SpecialDatesScheduler.apply(
|
||||||
|
context = this@CalendulaApp,
|
||||||
|
enabled = deps.settingsPrefs().specialDatesEnabled.first(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,23 +23,29 @@ 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.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.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.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
|
||||||
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.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
|
||||||
@@ -57,6 +64,11 @@ 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)
|
||||||
|
|
||||||
// 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,6 +93,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
requestedDetailKey = intent.detailKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
|
requestedInsertForm = intent.insertFormOrNull()
|
||||||
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
|
||||||
@@ -99,9 +112,22 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val use24Hour = remember(settings.timeFormat, context) {
|
val use24Hour = remember(settings.timeFormat, context) {
|
||||||
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
}
|
}
|
||||||
|
// The user's custom-font choice, resolved to a Material typography
|
||||||
|
// (issue #19). Recomputed only when a token — or the custom-font
|
||||||
|
// re-import stamp AppFontSettings carries, so replacing the file
|
||||||
|
// behind an active "custom" token still refreshes — changes;
|
||||||
|
// "system for both" returns the default scale untouched.
|
||||||
|
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
|
val typography = remember(fonts, context) {
|
||||||
|
calendulaTypography(
|
||||||
|
brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context),
|
||||||
|
plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context),
|
||||||
|
)
|
||||||
|
}
|
||||||
CalendulaTheme(
|
CalendulaTheme(
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
|
typography = typography,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
@@ -116,6 +142,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = { requestedInsertForm = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A persistent corner marker so a debug build is never
|
// A persistent corner marker so a debug build is never
|
||||||
@@ -153,6 +181,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
intent.detailKeyOrNull()?.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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,6 +201,31 @@ 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 `ACTION_INSERT` launch — another
|
||||||
|
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
||||||
|
* 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? {
|
||||||
|
if (action != Intent.ACTION_INSERT) 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/
|
||||||
@@ -193,6 +247,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// An external date tap (launcher/clock) has no widget source, so it opens
|
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||||
// the day view rooted over the default home view (OpenDate source = null).
|
// the day view rooted over the default home view (OpenDate source = null).
|
||||||
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||||
|
// A widget header tap: open a top-level view with no date drill-in. The
|
||||||
|
// empty string carried by [openViewIntent] means "the default home view".
|
||||||
|
if (hasExtra(EXTRA_OPEN_VIEW)) {
|
||||||
|
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
|
||||||
|
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
|
||||||
|
}
|
||||||
val source = sourceViewOrNull()
|
val source = sourceViewOrNull()
|
||||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
return when {
|
return when {
|
||||||
@@ -244,6 +304,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||||
|
|
||||||
|
// A widget header tap asking to open a top-level view (no date drill-in).
|
||||||
|
// Its value is the target [CalendarView] name, or "" for the default view.
|
||||||
|
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
|
||||||
|
|
||||||
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
||||||
// in-app back stack in that view; absent for non-widget launches (reminders).
|
// in-app back stack in that view; absent for non-widget launches (reminders).
|
||||||
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
||||||
@@ -307,5 +371,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app on a top-level [view] with no date drill-in — a widget
|
||||||
|
* header tap. A null [view] opens the user's default home view (the agenda
|
||||||
|
* widget's "Upcoming" title); a concrete view roots there over the default
|
||||||
|
* home (the month widget's month/year title → [CalendarView.Month]). The
|
||||||
|
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
|
||||||
|
*/
|
||||||
|
fun openViewIntent(context: Context, view: CalendarView?): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://view/${view?.name ?: "default"}".toUri()
|
||||||
|
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes(
|
|||||||
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next date on or after [today] carrying [month]/[day] — the date to sample
|
||||||
|
* a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an
|
||||||
|
* ancient anchor (1972 for year-less dates), a year whose timezone rules
|
||||||
|
* (pre-DST offsets) differ from today's; sampling the offset there skews every
|
||||||
|
* modern occurrence by hours. Sampling at the upcoming occurrence makes the
|
||||||
|
* stored offset correct for it and its neighbours (only ±1h DST drift remains,
|
||||||
|
* the inherent limit). Feb-29 skips forward to the next leap year.
|
||||||
|
*/
|
||||||
|
internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): LocalDate {
|
||||||
|
var year = today.year
|
||||||
|
// A Feb-29 date is valid only every ~4 years; 8 tries always reaches one.
|
||||||
|
repeat(8) {
|
||||||
|
val candidate = runCatching { LocalDate.of(year, month, day) }.getOrNull()
|
||||||
|
if (candidate != null && !candidate.isBefore(today)) return candidate
|
||||||
|
year++
|
||||||
|
}
|
||||||
|
// Unreachable for real month/day inputs; fall back to the raw anchor.
|
||||||
|
return LocalDate.of(today.year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||||
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||||
|
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||||
|
* hue (the managed special-dates calendars), so they can't drift from the
|
||||||
|
* swatches offered in the colour picker.
|
||||||
|
*/
|
||||||
|
object CalendarColorPalette {
|
||||||
|
val Red = 0xFFD50000.toInt()
|
||||||
|
val Orange = 0xFFE67C00.toInt()
|
||||||
|
val Amber = 0xFFF6BF26.toInt()
|
||||||
|
val Green = 0xFF33B679.toInt()
|
||||||
|
val DarkGreen = 0xFF0B8043.toInt()
|
||||||
|
val Blue = 0xFF039BE5.toInt()
|
||||||
|
val Indigo = 0xFF3F51B5.toInt()
|
||||||
|
val Purple = 0xFF8E24AA.toInt()
|
||||||
|
val Graphite = 0xFF616161.toInt()
|
||||||
|
|
||||||
|
/** The full palette, in swatch-row order. */
|
||||||
|
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||||
|
}
|
||||||
@@ -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,6 +18,8 @@ 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
|
||||||
@@ -24,10 +28,14 @@ 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.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
|
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.util.UUID
|
import java.util.UUID
|
||||||
@@ -67,9 +75,11 @@ interface CalendarDataSource {
|
|||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -97,6 +107,55 @@ interface CalendarDataSource {
|
|||||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||||
fun deleteCalendar(id: Long)
|
fun deleteCalendar(id: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a local calendar tagged as the special-dates mirror for [type]
|
||||||
|
* (a marker in `CAL_SYNC2`); returns its `Calendars._ID`. Otherwise a normal
|
||||||
|
* local calendar, so it inherits per-calendar colour/visibility/reminders.
|
||||||
|
*/
|
||||||
|
fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's special-dates calendars, found by their `CAL_SYNC2` marker.
|
||||||
|
* Lets the mirror re-adopt its calendars after the stored ids are lost, and
|
||||||
|
* detect when the user deleted one in system settings.
|
||||||
|
*/
|
||||||
|
fun findManagedCalendars(): List<ManagedCalendarRow>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The managed events in [calendarId] (those carrying a `UID_2445`, not
|
||||||
|
* soft-deleted) as [ManagedEventRow]s — the existing side of the sync diff.
|
||||||
|
*/
|
||||||
|
fun queryManagedEvents(calendarId: Long): List<ManagedEventRow>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a managed event carrying the deterministic [uid] in `UID_2445`;
|
||||||
|
* seeds its reminder rows from [form] (never touched again by sync). Returns
|
||||||
|
* the new `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
|
*/
|
||||||
|
fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite exactly the given managed columns on [eventId] (keys are
|
||||||
|
* `CalendarContract.Events` columns; a null value clears the column). A
|
||||||
|
* bare, targeted update — it never reconciles reminders or attendees, so
|
||||||
|
* user-owned data on the event survives every sync. No-ops on an empty map.
|
||||||
|
*/
|
||||||
|
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the reminders on **every managed** event in [calendarId] with
|
||||||
|
* [minutes] (all-day lead times), each encoded so it fires at
|
||||||
|
* [allDayReminderTimeMinutes] on its upcoming occurrence. Used by the
|
||||||
|
* special-dates section so a reminder change applies to existing events too,
|
||||||
|
* not just future ones — the managed calendars treat reminders as a
|
||||||
|
* calendar-level setting. Skips any non-managed (user) event in the calendar.
|
||||||
|
*/
|
||||||
|
fun applyManagedCalendarReminders(
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
minutes: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
* Insert a new event; returns the new `Events._ID`. [allDayReminderTimeMinutes]
|
||||||
* (minutes from local midnight) is the wall-clock time all-day reminders
|
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||||
@@ -119,6 +178,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
|
||||||
@@ -292,6 +370,140 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
if (deleted == 0) throw WriteFailedException("delete calendar id=$id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
|
val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
|
||||||
|
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
|
||||||
|
put(CalendarContract.Calendars.OWNER_ACCOUNT, LOCAL_ACCOUNT_NAME)
|
||||||
|
put(CalendarContract.Calendars.NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
|
||||||
|
put(CalendarContract.Calendars.CALENDAR_COLOR, color)
|
||||||
|
put(
|
||||||
|
CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL,
|
||||||
|
CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||||
|
)
|
||||||
|
put(CalendarContract.Calendars.VISIBLE, 1)
|
||||||
|
put(CalendarContract.Calendars.SYNC_EVENTS, 1)
|
||||||
|
// The marker that identifies this as our special-dates calendar,
|
||||||
|
// independent of the (user-editable) display name. CAL_SYNC1 already
|
||||||
|
// holds the description, so the marker lives in CAL_SYNC2.
|
||||||
|
put(MANAGED_MARKER_COLUMN, markerFor(type))
|
||||||
|
}
|
||||||
|
val uri = resolver.insert(localCalendarsUri(), values)
|
||||||
|
?: throw WriteFailedException("create managed calendar")
|
||||||
|
return ContentUris.parseId(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findManagedCalendars(): List<ManagedCalendarRow> = resolver.query(
|
||||||
|
CalendarContract.Calendars.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Calendars._ID, MANAGED_MARKER_COLUMN),
|
||||||
|
"${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " +
|
||||||
|
"${CalendarContract.Calendars.ACCOUNT_NAME} = ? AND " +
|
||||||
|
"$MANAGED_MARKER_COLUMN IS NOT NULL",
|
||||||
|
arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL, LOCAL_ACCOUNT_NAME),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val type = typeForMarker(c.getString(1)) ?: continue
|
||||||
|
add(ManagedCalendarRow(id = c.getLong(0), type = type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
),
|
||||||
|
// Only our own mirror events (UID prefix), so a user event that somehow
|
||||||
|
// landed in this calendar (e.g. an .ics import) is never seen as
|
||||||
|
// "existing but not desired" and deleted by the diff.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||||
|
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val uid = c.getString(1)?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
add(
|
||||||
|
ManagedEventRow(
|
||||||
|
eventId = c.getLong(0),
|
||||||
|
uid = uid,
|
||||||
|
title = c.getString(2).orEmpty(),
|
||||||
|
dtStartMillis = c.getLong(3),
|
||||||
|
rrule = c.getString(4)?.takeIf { it.isNotEmpty() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||||
|
// Deterministic UID = the reconciliation key. A re-sync reads it back
|
||||||
|
// (queryManagedEvents) and diffs on it, so a contact never doubles.
|
||||||
|
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||||
|
val values = buildEventInsertValues(form, uid, times)
|
||||||
|
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||||
|
?.let(ContentUris::parseId)
|
||||||
|
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
|
||||||
|
// Seeded once, then never re-touched by sync — so a user who moves a
|
||||||
|
// birthday reminder keeps their change. Encoded against the upcoming
|
||||||
|
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
|
||||||
|
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
insertReminderRows(
|
||||||
|
eventId,
|
||||||
|
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateManagedFields(eventId: Long, values: Map<String, Any?>) {
|
||||||
|
if (values.isEmpty()) return
|
||||||
|
val rows = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(),
|
||||||
|
null, null,
|
||||||
|
)
|
||||||
|
if (rows == 0) throw WriteFailedException("update managed event id=$eventId")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun applyManagedCalendarReminders(
|
||||||
|
calendarId: Long,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
minutes: List<Int>,
|
||||||
|
) {
|
||||||
|
resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
|
||||||
|
// Only our own mirror events, so a stray user event in this calendar
|
||||||
|
// isn't all-day-re-encoded or stripped of its own reminders.
|
||||||
|
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||||
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
|
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||||
|
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val eventId = c.getLong(0)
|
||||||
|
// Managed events are all-day: DTSTART is a UTC midnight anchor.
|
||||||
|
val anchor = Instant.ofEpochMilli(c.getLong(1))
|
||||||
|
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||||
|
reconcileReminders(
|
||||||
|
eventId,
|
||||||
|
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> {
|
||||||
ensureObserversRegistered()
|
ensureObserversRegistered()
|
||||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||||
@@ -424,12 +636,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
?: 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()
|
||||||
|
|
||||||
@@ -575,35 +794,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
.distinct()
|
.distinct()
|
||||||
|
|
||||||
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(
|
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
|
||||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
|
||||||
)
|
|
||||||
// A globally-unique UID so a later .ics backup/restore can identify
|
// A globally-unique UID so a later .ics backup/restore can identify
|
||||||
// the event and not duplicate it on re-import (the provider leaves
|
// the event and not duplicate it on re-import (the provider leaves
|
||||||
// this null for events it didn't sync). Older rows without one fall
|
// this null for events it didn't sync). Older rows without one fall
|
||||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||||
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
val values = buildEventInsertValues(
|
||||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
form,
|
||||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
uid = "${UUID.randomUUID()}@calendula",
|
||||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
times = form.toWriteTimes(ZoneId.systemDefault()),
|
||||||
// The provider's invariant: recurring rows carry RRULE+DURATION
|
).toContentValues().apply {
|
||||||
// (and no DTEND), one-off rows carry DTEND.
|
|
||||||
if (form.rrule == null) {
|
|
||||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
|
||||||
} else {
|
|
||||||
put(CalendarContract.Events.RRULE, form.rrule)
|
|
||||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
|
||||||
}
|
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
|
||||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
|
||||||
form.location.trim().takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
|
||||||
form.description.trim().takeIf { it.isNotEmpty() }
|
|
||||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
|
||||||
// A null colour just leaves both columns unset (the event inherits
|
// A null colour just leaves both columns unset (the event inherits
|
||||||
// its calendar's colour), so only the key/raw cases are written.
|
// its calendar's colour), so only the key/raw cases are written.
|
||||||
when {
|
when {
|
||||||
@@ -615,10 +814,25 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||||
val eventId = ContentUris.parseId(uri)
|
val eventId = ContentUris.parseId(uri)
|
||||||
// Best effort (spec §8): the event exists at this point — a reminder
|
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||||
// that fails to attach is logged, not surfaced as a failed create.
|
// Guests are best-effort like reminders: a row that fails to attach is
|
||||||
encodedReminders(form, allDayReminderTimeMinutes)
|
// logged, not surfaced as a failed create. Calendula never sends an
|
||||||
.forEach { minutes ->
|
// invitation — it only writes the rows; the backend decides delivery.
|
||||||
|
insertAttendees(eventId, form.attendees)
|
||||||
|
return eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach [form]'s reminders to a freshly inserted event. Best effort
|
||||||
|
* (spec §8): the event exists at this point — a reminder that fails to
|
||||||
|
* attach is logged, not surfaced as a failed create.
|
||||||
|
*/
|
||||||
|
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) =
|
||||||
|
insertReminderRows(eventId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
|
|
||||||
|
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
|
||||||
|
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
|
||||||
|
encodedMinutes.forEach { minutes ->
|
||||||
val reminder = ContentValues().apply {
|
val reminder = ContentValues().apply {
|
||||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||||
@@ -628,11 +842,25 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Guests are best-effort like reminders: a row that fails to attach is
|
}
|
||||||
// logged, not surfaced as a failed create. Calendula never sends an
|
|
||||||
// invitation — it only writes the rows; the backend decides delivery.
|
/**
|
||||||
insertAttendees(eventId, form.attendees)
|
* A managed all-day event's reminders, encoded so they fire at
|
||||||
return eventId
|
* [allDayReminderTimeMinutes] on its **upcoming** occurrence. The event's
|
||||||
|
* DTSTART is an ancient recurrence anchor (see [nextYearlyOccurrence]); using
|
||||||
|
* it directly would sample a stale timezone offset, so the offset is sampled
|
||||||
|
* at the next occurrence of the anchor's month/day instead.
|
||||||
|
*/
|
||||||
|
private fun managedAllDayReminderMinutes(
|
||||||
|
anchor: LocalDate,
|
||||||
|
reminders: List<Int>,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): List<Int> {
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val onDate = nextYearlyOccurrence(anchor.monthValue, anchor.dayOfMonth, LocalDate.now(zone))
|
||||||
|
return reminders
|
||||||
|
.map { toProviderAllDayMinutes(it, onDate, zone, allDayReminderTimeMinutes) }
|
||||||
|
.distinct()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateEvent(
|
override fun updateEvent(
|
||||||
@@ -669,6 +897,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,
|
||||||
@@ -1038,6 +1450,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The `CAL_SYNC2` marker value identifying a managed calendar's [type]. */
|
||||||
|
private fun markerFor(type: SpecialDateType): String = when (type) {
|
||||||
|
SpecialDateType.Birthday -> "$MANAGED_MARKER_PREFIX:birthday"
|
||||||
|
SpecialDateType.Anniversary -> "$MANAGED_MARKER_PREFIX:anniversary"
|
||||||
|
SpecialDateType.Custom -> "$MANAGED_MARKER_PREFIX:custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of [markerFor]; null for a value that isn't one of ours. */
|
||||||
|
private fun typeForMarker(marker: String?): SpecialDateType? =
|
||||||
|
SpecialDateType.entries.firstOrNull { markerFor(it) == marker }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "CalendarDataSource"
|
const val TAG = "CalendarDataSource"
|
||||||
|
|
||||||
@@ -1047,6 +1470,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column and namespace for the special-dates marker (shared with the
|
||||||
|
* calendar projection, which reads it into [CalendarSource.isManaged]).
|
||||||
|
* CAL_SYNC1 already holds the local-calendar description, so it uses CAL_SYNC2.
|
||||||
|
*/
|
||||||
|
val MANAGED_MARKER_COLUMN: String = CalendarProjection.MANAGED_MARKER_COLUMN
|
||||||
|
const val MANAGED_MARKER_PREFIX = CalendarProjection.MANAGED_MARKER_PREFIX
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead/behind a search looks for a recurring event's nearest
|
* How far ahead/behind a search looks for a recurring event's nearest
|
||||||
* occurrence (~2 years). Wide enough for everyday series; a series that
|
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||||
|
|||||||
@@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
// A special-dates mirror calendar, recognised by its durable CAL_SYNC2
|
||||||
|
// marker (only meaningful on the local calendars the app owns). This is
|
||||||
|
// the source of truth for the editor lock — independent of any stored
|
||||||
|
// preference that a backup restore could have wiped.
|
||||||
|
isManaged = isLocal &&
|
||||||
|
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||||
|
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -23,26 +24,29 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||||
|
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||||
if (begin < 0L) {
|
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
// 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
|
return null
|
||||||
}
|
}
|
||||||
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
// 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. Keep the event (end == begin); callers that opened a
|
||||||
// specific occurrence supply the real per-occurrence times from
|
// specific occurrence supply the real per-occurrence times from
|
||||||
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
|
// CalendarContract.Instances. 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's coerceAtLeast).
|
||||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin
|
begin
|
||||||
} 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
|
||||||
|
|||||||
@@ -43,10 +43,55 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
* 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values for a brand-new Events row: identity, times (the provider's
|
||||||
|
* invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows
|
||||||
|
* carry DTEND), availability/access level and trimmed optional text. Shared by
|
||||||
|
* the user-event and managed-event insert paths, which differ only in the
|
||||||
|
* [uid] they stamp; colour and attendees are user-event concerns the caller
|
||||||
|
* layers on top.
|
||||||
|
*/
|
||||||
|
internal fun buildEventInsertValues(
|
||||||
|
form: EventForm,
|
||||||
|
uid: String,
|
||||||
|
times: EventWriteTimes,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.CALENDAR_ID,
|
||||||
|
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.UID_2445, uid)
|
||||||
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
|
if (form.rrule == null) {
|
||||||
|
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||||
|
} else {
|
||||||
|
put(CalendarContract.Events.RRULE, form.rrule)
|
||||||
|
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||||
|
}
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
form.location.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||||
|
form.description.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,6 +191,121 @@ 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) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the app's special-dates calendars, discovered by the marker stamped in
|
||||||
|
* its `CAL_SYNC2` column — so the mirror can re-adopt its calendars even if the
|
||||||
|
* stored ids in preferences were lost (e.g. an app-data wipe).
|
||||||
|
*/
|
||||||
|
data class ManagedCalendarRow(val id: Long, val type: SpecialDateType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A managed event as read back for the sync diff. [uid] is the deterministic
|
||||||
|
* `Events.UID_2445` (`contact-<type>:<lookupKey>@calendula`) the mirror keys on;
|
||||||
|
* [title], [dtStartMillis] and [rrule] are the managed columns compared against
|
||||||
|
* the desired state to decide whether a targeted update is needed.
|
||||||
|
*/
|
||||||
|
data class ManagedEventRow(
|
||||||
|
val eventId: Long,
|
||||||
|
val uid: String,
|
||||||
|
val title: String,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
)
|
||||||
@@ -15,9 +15,17 @@ internal object CalendarProjection {
|
|||||||
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
||||||
// so the mapper only reads it for local calendars).
|
// so the mapper only reads it for local calendars).
|
||||||
DESCRIPTION_COLUMN,
|
DESCRIPTION_COLUMN,
|
||||||
|
// The special-dates marker (CAL_SYNC2) — the durable identity the app
|
||||||
|
// uses to recognise its own managed calendars, independent of any
|
||||||
|
// stored preference id (which a backup restore / data wipe can lose).
|
||||||
|
MANAGED_MARKER_COLUMN,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||||
|
const val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||||
|
|
||||||
|
/** Namespace prefix of every managed-calendar [MANAGED_MARKER_COLUMN] value. */
|
||||||
|
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
const val IDX_DISPLAY_NAME = 1
|
const val IDX_DISPLAY_NAME = 1
|
||||||
@@ -27,6 +35,7 @@ internal object CalendarProjection {
|
|||||||
const val IDX_VISIBLE = 5
|
const val IDX_VISIBLE = 5
|
||||||
const val IDX_ACCESS_LEVEL = 6
|
const val IDX_ACCESS_LEVEL = 6
|
||||||
const val IDX_DESCRIPTION = 7
|
const val IDX_DESCRIPTION = 7
|
||||||
|
const val IDX_MANAGED_MARKER = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
internal object InstanceProjection {
|
internal object InstanceProjection {
|
||||||
@@ -177,6 +186,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()
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the localized calendar names/templates and per-type colours the
|
||||||
|
* mirror creates its calendars with. The colours are picked from the shared
|
||||||
|
* palette so managed calendars look native alongside user calendars.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : SpecialDatesCalendarSpec {
|
||||||
|
|
||||||
|
override fun displayName(type: SpecialDateType): String = context.getString(
|
||||||
|
when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
|
||||||
|
when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun color(type: SpecialDateType): Int = when (type) {
|
||||||
|
SpecialDateType.Birthday -> CalendarColorPalette.Purple
|
||||||
|
SpecialDateType.Anniversary -> CalendarColorPalette.Red
|
||||||
|
SpecialDateType.Custom -> CalendarColorPalette.Blue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.provider.ContactsContract.CommonDataKinds.Event
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the dated `Event` rows (birthdays, anniversaries, custom dates) of the
|
||||||
|
* device's contacts. Read-only and offline — the one-way source for the
|
||||||
|
* special-dates mirror. Requires `READ_CONTACTS`; returns an empty list when
|
||||||
|
* the permission is absent so the sync can degrade to a stalled state rather
|
||||||
|
* than crash.
|
||||||
|
*/
|
||||||
|
interface ContactSpecialDatesDataSource {
|
||||||
|
fun hasPermission(): Boolean
|
||||||
|
|
||||||
|
/** All usable contact special-dates, deduplicated per contact and type. */
|
||||||
|
fun readSpecialDates(): List<ContactSpecialDate>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
||||||
|
fun Context.hasContactsPermission(): Boolean =
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||||
|
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||||
|
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||||
|
Event.TYPE_ANNIVERSARY -> SpecialDateType.Anniversary
|
||||||
|
else -> SpecialDateType.Custom
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ContactSpecialDatesDataSource {
|
||||||
|
|
||||||
|
override fun hasPermission(): Boolean = context.hasContactsPermission()
|
||||||
|
|
||||||
|
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||||
|
if (!hasPermission()) return emptyList()
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
// A contact can carry the same date more than once (multiple raw contacts
|
||||||
|
// under one aggregate); dedup on the mirror's reconciliation key so exact
|
||||||
|
// duplicates collapse while genuinely distinct dates (two custom events on
|
||||||
|
// one contact) are all kept. Ordered by Data._ID so which of two truly
|
||||||
|
// conflicting rows wins is stable across syncs (no event ping-pong).
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
val result = ArrayList<ContactSpecialDate>()
|
||||||
|
runCatching {
|
||||||
|
resolver.query(
|
||||||
|
ContactsContract.Data.CONTENT_URI,
|
||||||
|
PROJECTION,
|
||||||
|
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||||
|
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||||
|
"${ContactsContract.Data._ID} ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||||
|
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||||
|
val idxLabel = c.getColumnIndexOrThrow(Event.LABEL)
|
||||||
|
val idxLookup = c.getColumnIndexOrThrow(ContactsContract.Data.LOOKUP_KEY)
|
||||||
|
val idxName = c.getColumnIndexOrThrow(ContactsContract.Data.DISPLAY_NAME_PRIMARY)
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
|
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||||
|
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||||
|
val date = ContactSpecialDate(
|
||||||
|
lookupKey = lookup,
|
||||||
|
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||||
|
type = type,
|
||||||
|
month = parts.month,
|
||||||
|
day = parts.day,
|
||||||
|
year = parts.year,
|
||||||
|
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
if (seen.add(date.managedUid())) result += date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "ContactSpecialDates"
|
||||||
|
|
||||||
|
val PROJECTION = arrayOf(
|
||||||
|
ContactsContract.Data.LOOKUP_KEY,
|
||||||
|
ContactsContract.Data.DISPLAY_NAME_PRIMARY,
|
||||||
|
Event.START_DATE,
|
||||||
|
Event.TYPE,
|
||||||
|
Event.LABEL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules the contact special-dates mirror. Birthdays change rarely, so this
|
||||||
|
* is deliberately cheap: a **daily** periodic reconcile, plus an immediate run
|
||||||
|
* on enable / "Sync now" and a debounced one when the app is foregrounded. No
|
||||||
|
* ContentObserver — it would need the process alive and buys almost nothing for
|
||||||
|
* once-a-year events. Everything stays offline (local contacts → local calendar).
|
||||||
|
*/
|
||||||
|
object SpecialDatesScheduler {
|
||||||
|
|
||||||
|
private const val WORK_NAME = "special-dates-sync"
|
||||||
|
private const val WORK_NAME_NOW = "special-dates-sync-now"
|
||||||
|
private const val WORK_NAME_FOREGROUND = "special-dates-sync-foreground"
|
||||||
|
private const val KEY_FOREGROUND = "foreground"
|
||||||
|
|
||||||
|
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
|
||||||
|
fun apply(context: Context, enabled: Boolean) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
if (!enabled) {
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME)
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME_FOREGROUND)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
|
||||||
|
// Delay the first periodic run so it never overlaps an immediate run.
|
||||||
|
.setInitialDelay(1, TimeUnit.DAYS)
|
||||||
|
.build()
|
||||||
|
workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one reconcile immediately. [foreground] runs (the app resuming) are
|
||||||
|
* debounced inside the worker and use their own work name, so a frequent
|
||||||
|
* foreground resync can never REPLACE — and swallow — a pending enable /
|
||||||
|
* "Sync now" run, which always syncs. The engine serializes the two if they
|
||||||
|
* overlap.
|
||||||
|
*/
|
||||||
|
fun runNow(context: Context, foreground: Boolean = false) {
|
||||||
|
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
|
||||||
|
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
|
||||||
|
.build()
|
||||||
|
val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt
|
||||||
|
* [EntryPoint] so it works under WorkManager's default worker factory. Records
|
||||||
|
* the run for the settings status line; a missing permission parks the feature
|
||||||
|
* in a stalled state (surfaced in settings) rather than retrying forever.
|
||||||
|
*/
|
||||||
|
class SpecialDatesSyncWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun settingsPrefs(): SettingsPrefs
|
||||||
|
fun syncEngine(): SpecialDatesSyncEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||||
|
val prefs = deps.settingsPrefs()
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
// A foreground resume can fire often — skip if we synced recently.
|
||||||
|
val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false)
|
||||||
|
if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) {
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
when (val result = deps.syncEngine().sync()) {
|
||||||
|
// The engine re-checks the toggle, so already-queued work never
|
||||||
|
// revives a disabled feature; don't record a run either.
|
||||||
|
SpecialDatesSyncResult.Disabled -> return Result.success()
|
||||||
|
else -> {
|
||||||
|
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
|
||||||
|
SpecialDatesStalledReason.PermissionRevoked
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
prefs.recordSpecialDatesRun(now, stalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
|
||||||
|
Result.success()
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// A revoked calendar/contacts permission won't fix itself on retry —
|
||||||
|
// park the feature in a stalled state (surfaced in settings) instead
|
||||||
|
// of retrying with backoff forever.
|
||||||
|
Log.w(TAG, "Special-dates sync lacks a required permission", e)
|
||||||
|
prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked)
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Special-dates sync failed", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SpecialDatesSync"
|
||||||
|
|
||||||
|
/** Skip a foreground-triggered sync if one ran within this window (4h). */
|
||||||
|
private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
|
||||||
|
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized, per-type presentation the engine needs but can't derive purely
|
||||||
|
* (calendar display name, colour, and the default title template). Kept behind
|
||||||
|
* an interface so the engine's diff stays unit-testable without Android
|
||||||
|
* resources.
|
||||||
|
*/
|
||||||
|
interface SpecialDatesCalendarSpec {
|
||||||
|
fun displayName(type: SpecialDateType): String
|
||||||
|
fun color(type: SpecialDateType): Int
|
||||||
|
fun defaultTitleTemplate(type: SpecialDateType): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored title template for [type], falling back to the localized default
|
||||||
|
* when blank/absent — the one resolution rule shared by the sync (what gets
|
||||||
|
* written) and the settings editor (what gets shown).
|
||||||
|
*/
|
||||||
|
fun SpecialDatesCalendarSpec.resolveTitleTemplate(
|
||||||
|
type: SpecialDateType,
|
||||||
|
stored: Map<SpecialDateType, String>,
|
||||||
|
): String = stored[type]?.takeIf { it.isNotBlank() } ?: defaultTitleTemplate(type)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
|
||||||
|
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
|
||||||
|
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
|
||||||
|
* fields once), changed contacts get a *targeted* managed-column update, and
|
||||||
|
* removed contacts are deleted — so a user's own edits (reminders, location,
|
||||||
|
* notes) are never clobbered. See docs/design/contact-special-dates.md.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SpecialDatesSyncEngine @Inject constructor(
|
||||||
|
private val contacts: ContactSpecialDatesDataSource,
|
||||||
|
private val calendars: CalendarDataSource,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
private val spec: SpecialDatesCalendarSpec,
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Serializes calendar-lifecycle work: two overlapping syncs (the daily job
|
||||||
|
// racing a "Sync now"/foreground run) would each see no managed calendar and
|
||||||
|
// both create one; a teardown racing an in-flight sync would delete calendars
|
||||||
|
// the sync then recreates. Holding this for the whole of sync()/teardown()
|
||||||
|
// makes those check-then-act sequences atomic, and — because sync() re-reads
|
||||||
|
// the enabled flag inside the lock — a teardown always wins the race.
|
||||||
|
private val lifecycleMutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile every enabled type against the device's contacts. Returns why it
|
||||||
|
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
|
||||||
|
*/
|
||||||
|
suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock {
|
||||||
|
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||||
|
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||||
|
|
||||||
|
val enabledTypes = prefs.specialDatesTypes.first()
|
||||||
|
val calendarByType = reconcileCalendars(enabledTypes)
|
||||||
|
|
||||||
|
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
|
||||||
|
val reminderCtx = readReminderContext()
|
||||||
|
val templates = prefs.specialDatesTitleTemplates.first()
|
||||||
|
val showYear = prefs.specialDatesShowYear.first()
|
||||||
|
|
||||||
|
calendarByType.forEach { (type, calendarId) ->
|
||||||
|
val template = spec.resolveTitleTemplate(type, templates)
|
||||||
|
syncType(
|
||||||
|
calendarId = calendarId,
|
||||||
|
type = type,
|
||||||
|
contactsOfType = desiredByType[type].orEmpty(),
|
||||||
|
template = template,
|
||||||
|
showYear = showYear,
|
||||||
|
reminderCtx = reminderCtx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SpecialDatesSyncResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the reminder default for a type's managed calendar and apply it to
|
||||||
|
* **all** its existing events too (not just future ones) — for managed
|
||||||
|
* calendars the reminder is a calendar-level setting. Persists the per-calendar
|
||||||
|
* all-day override so new events keep matching. No-op if the calendar for
|
||||||
|
* [type] doesn't exist yet.
|
||||||
|
*/
|
||||||
|
suspend fun applyReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||||
|
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||||
|
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||||
|
val minutes = when (override) {
|
||||||
|
ReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||||
|
ReminderOverride.None -> emptyList()
|
||||||
|
is ReminderOverride.Minutes -> override.minutes
|
||||||
|
}
|
||||||
|
calendars.applyManagedCalendarReminders(
|
||||||
|
calendarId = calendarId,
|
||||||
|
allDayReminderTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||||
|
minutes = minutes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
|
||||||
|
suspend fun teardown() = lifecycleMutex.withLock {
|
||||||
|
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
|
||||||
|
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure each enabled type has exactly one managed calendar (adopting an
|
||||||
|
* existing one, or the stored id if it still exists, else creating one) and
|
||||||
|
* that disabled types have none. Returns the calendar id per enabled type.
|
||||||
|
*/
|
||||||
|
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
|
||||||
|
val foundByType = calendars.findManagedCalendars()
|
||||||
|
.groupBy({ it.type }, { it.id })
|
||||||
|
.mapValues { it.value.first() }
|
||||||
|
val stored = prefs.specialDatesCalendars.first()
|
||||||
|
val result = LinkedHashMap<SpecialDateType, Long>()
|
||||||
|
for (type in SpecialDateType.entries) {
|
||||||
|
// A stored id counts only if the calendar still exists (the user may
|
||||||
|
// have deleted it in system settings); otherwise adopt a found one.
|
||||||
|
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
|
||||||
|
?: foundByType[type]
|
||||||
|
if (type in enabledTypes) {
|
||||||
|
val id = existingId ?: createCalendar(type)
|
||||||
|
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
|
||||||
|
result[type] = id
|
||||||
|
} else {
|
||||||
|
if (existingId != null) calendars.deleteCalendar(existingId)
|
||||||
|
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createCalendar(type: SpecialDateType): Long {
|
||||||
|
// reconcileCalendars persists the id — the single place ids are recorded.
|
||||||
|
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
|
||||||
|
// Seed a useful all-day reminder default (on the day + a week before), so
|
||||||
|
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
|
||||||
|
// only set when the user hasn't already configured this calendar.
|
||||||
|
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||||
|
prefs.setCalendarAllDayReminderOverride(
|
||||||
|
id,
|
||||||
|
ReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncType(
|
||||||
|
calendarId: Long,
|
||||||
|
type: SpecialDateType,
|
||||||
|
contactsOfType: List<ContactSpecialDate>,
|
||||||
|
template: String,
|
||||||
|
showYear: Boolean,
|
||||||
|
reminderCtx: ReminderContext,
|
||||||
|
) {
|
||||||
|
val built = contactsOfType.map { sd ->
|
||||||
|
buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx)
|
||||||
|
}
|
||||||
|
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||||
|
val formByUid = built.associate { it.desired.uid to it.form }
|
||||||
|
diff.insertUids.forEach { uid ->
|
||||||
|
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
|
||||||
|
}
|
||||||
|
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
|
||||||
|
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildManagedEvent(
|
||||||
|
calendarId: Long,
|
||||||
|
type: SpecialDateType,
|
||||||
|
sd: ContactSpecialDate,
|
||||||
|
template: String,
|
||||||
|
showYear: Boolean,
|
||||||
|
reminderCtx: ReminderContext,
|
||||||
|
): BuiltManagedEvent {
|
||||||
|
val anchor = sd.anchorDate()
|
||||||
|
val start = LocalDateTime(anchor, LocalTime(0, 0))
|
||||||
|
// The source year is static and correct on every occurrence (unlike age).
|
||||||
|
val year = if (showYear) sd.year else null
|
||||||
|
val title = renderSpecialDateTitle(template, sd.displayName, year)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = calendarId,
|
||||||
|
title = title,
|
||||||
|
isAllDay = true,
|
||||||
|
start = start,
|
||||||
|
end = start,
|
||||||
|
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||||
|
availability = Availability.Free,
|
||||||
|
rrule = YEARLY_RRULE,
|
||||||
|
)
|
||||||
|
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||||
|
return BuiltManagedEvent(
|
||||||
|
desired = ManagedEventDesired(
|
||||||
|
uid = sd.managedUid(),
|
||||||
|
title = title,
|
||||||
|
dtStartMillis = dtStartMillis,
|
||||||
|
rrule = YEARLY_RRULE,
|
||||||
|
),
|
||||||
|
form = form,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Managed events are always all-day, so only the all-day defaults apply.
|
||||||
|
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
|
||||||
|
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
|
||||||
|
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
|
||||||
|
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderContext(
|
||||||
|
val allDayGlobal: List<Int>,
|
||||||
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
|
val allDayTimeMinutes: Int,
|
||||||
|
) {
|
||||||
|
/** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */
|
||||||
|
fun resolveAllDay(calendarId: Long): List<Int> =
|
||||||
|
allDayOverrides[calendarId] ?: allDayGlobal
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
|
||||||
|
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
|
||||||
|
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The managed columns of a desired event, compared against the existing row. */
|
||||||
|
internal data class ManagedEventDesired(
|
||||||
|
val uid: String,
|
||||||
|
val title: String,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
|
||||||
|
|
||||||
|
internal data class ManagedDiff(
|
||||||
|
val insertUids: List<String>,
|
||||||
|
val updates: List<ManagedFieldUpdate>,
|
||||||
|
val deleteEventIds: List<Long>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
|
||||||
|
* desired-not-existing → insert, existing-not-desired → delete, and for events
|
||||||
|
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
|
||||||
|
* so a re-run with no contact changes produces nothing, and a managed update
|
||||||
|
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
|
||||||
|
*/
|
||||||
|
internal fun diffManagedEvents(
|
||||||
|
desired: List<ManagedEventDesired>,
|
||||||
|
existing: List<ManagedEventRow>,
|
||||||
|
): ManagedDiff {
|
||||||
|
val desiredByUid = desired.associateBy { it.uid }
|
||||||
|
val existingByUid = existing.associateBy { it.uid }
|
||||||
|
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
|
||||||
|
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
|
||||||
|
val updates = buildList {
|
||||||
|
for (d in desired) {
|
||||||
|
val row = existingByUid[d.uid] ?: continue
|
||||||
|
val columns = buildMap<String, Any?> {
|
||||||
|
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
|
||||||
|
if (row.dtStartMillis != d.dtStartMillis) {
|
||||||
|
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
|
||||||
|
}
|
||||||
|
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
|
||||||
|
}
|
||||||
|
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ManagedDiff(insertUids, updates, deleteEventIds)
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
@@ -14,6 +14,10 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -45,6 +49,18 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindReminderAlertStore(
|
abstract fun bindReminderAlertStore(
|
||||||
impl: AndroidReminderAlertStore,
|
impl: AndroidReminderAlertStore,
|
||||||
): ReminderAlertStore
|
): ReminderAlertStore
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindContactSpecialDatesDataSource(
|
||||||
|
impl: AndroidContactSpecialDatesDataSource,
|
||||||
|
): ContactSpecialDatesDataSource
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSpecialDatesCalendarSpec(
|
||||||
|
impl: AndroidSpecialDatesCalendarSpec,
|
||||||
|
): SpecialDatesCalendarSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.fonts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
import android.graphics.fonts.Font as PlatformFont
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage for user-loaded custom fonts (issue #19). A font a user picks via the
|
||||||
|
* system file picker is copied into the app's private storage — one file per
|
||||||
|
* [FontRole] — so the selection survives even if the original is later moved or
|
||||||
|
* deleted, and never leaves the app. The chosen file is validated as a real font
|
||||||
|
* before it replaces the previous one, so a bad pick can't wedge the app-wide
|
||||||
|
* typography.
|
||||||
|
*/
|
||||||
|
object CustomFontStore {
|
||||||
|
|
||||||
|
private fun dir(context: Context): File = File(context.filesDir, "fonts")
|
||||||
|
|
||||||
|
/** The stored font file for [role] (may not exist yet). */
|
||||||
|
fun file(context: Context, role: FontRole): File =
|
||||||
|
File(dir(context), "${role.name.lowercase(Locale.ROOT)}.ttf")
|
||||||
|
|
||||||
|
fun exists(context: Context, role: FontRole): Boolean =
|
||||||
|
file(context, role).let { it.exists() && it.length() > 0 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy [uri] into per-role storage, first validating that it parses as a
|
||||||
|
* font. Returns true on success; on any failure the previous file is left
|
||||||
|
* untouched and false is returned (the caller keeps the old selection).
|
||||||
|
*/
|
||||||
|
fun import(context: Context, role: FontRole, uri: Uri): Boolean {
|
||||||
|
val target = file(context, role)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val tmp = File(target.parentFile, "${role.name.lowercase(Locale.ROOT)}.tmp")
|
||||||
|
return try {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
tmp.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
} ?: return false
|
||||||
|
// Font.Builder throws IOException for anything that isn't a valid
|
||||||
|
// font file (API 29+, which is our minSdk) — a cheap, reliable check.
|
||||||
|
PlatformFont.Builder(tmp).build()
|
||||||
|
if (target.exists() && !target.delete()) return false
|
||||||
|
tmp.renameTo(target)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
tmp.delete()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the custom font for [role] (used when switching away from it). */
|
||||||
|
fun clear(context: Context, role: FontRole) {
|
||||||
|
file(context, role).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,21 @@ 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.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
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.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -104,6 +114,47 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
||||||
|
* strings — "system", "custom", or a bundled font's token — resolved to a
|
||||||
|
* FontFamily at the theme layer; an unknown token degrades to the default.
|
||||||
|
*/
|
||||||
|
val brandFont: Flow<String> = store.data.map { prefs ->
|
||||||
|
prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
val plainFont: Flow<String> = store.data.map { prefs ->
|
||||||
|
prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setBrandFont(token: String) {
|
||||||
|
store.edit { it[BRAND_FONT_KEY] = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setPlainFont(token: String) {
|
||||||
|
store.edit { it[PLAIN_FONT_KEY] = token }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A per-role bump counter for the user-loaded custom font. Re-importing
|
||||||
|
* overwrites the same file under the same "custom" token, so the token alone
|
||||||
|
* can't signal the change; this stamp — carried into AppFontSettings — breaks
|
||||||
|
* value equality so the resolved FontFamily (and its previews) refresh. A
|
||||||
|
* missing key is 0 (existing installs); the first import bumps it to 1.
|
||||||
|
*/
|
||||||
|
val brandFontStamp: Flow<Int> = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 }
|
||||||
|
|
||||||
|
val plainFontStamp: Flow<Int> = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 }
|
||||||
|
|
||||||
|
/** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */
|
||||||
|
suspend fun bumpCustomFontStamp(role: FontRole) {
|
||||||
|
val key = when (role) {
|
||||||
|
FontRole.BRAND -> BRAND_FONT_STAMP_KEY
|
||||||
|
FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY
|
||||||
|
}
|
||||||
|
store.edit { it[key] = (it[key] ?: 0) + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||||
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
||||||
}
|
}
|
||||||
@@ -154,6 +205,19 @@ 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 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
|
||||||
@@ -202,6 +266,47 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-switch button customisation (#24): the ordered full view list plus
|
||||||
|
* which views are enabled in the cycle. Stored comma-joined by enum name, a
|
||||||
|
* "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing
|
||||||
|
* views are appended enabled and unknown names dropped, so a future view
|
||||||
|
* defaults into the cycle. An absent key means [QuickSwitchConfig.Default].
|
||||||
|
*/
|
||||||
|
val quickSwitchConfig: Flow<QuickSwitchConfig> = store.data.map { prefs ->
|
||||||
|
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
||||||
|
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomic read-modify-write of the quick-switch config: [transform] sees the
|
||||||
|
* value currently stored, parsed inside the edit block, not the async-echoed
|
||||||
|
* UI snapshot. Two rapid intents (a toggle then a drag) therefore compose
|
||||||
|
* instead of each re-serialising a stale copy of the other field over it.
|
||||||
|
*/
|
||||||
|
suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||||
|
prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
|
||||||
|
* are appended in default order and unknown names dropped. Absent key means
|
||||||
|
* [IMPLEMENTED_VIEWS] (the historical fixed order).
|
||||||
|
*/
|
||||||
|
val drawerViewOrder: Flow<List<CalendarView>> = store.data.map { prefs ->
|
||||||
|
parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
|
store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional event-form fields shown by default (the rest hide behind
|
* Optional event-form fields shown by default (the rest hide behind
|
||||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||||
@@ -349,14 +454,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,18 +471,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +532,95 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Contact special dates (issue #15) ------------------------------
|
||||||
|
|
||||||
|
/** Master switch for the contact special-dates mirror. Default OFF (opt-in). */
|
||||||
|
val specialDatesEnabled: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_ENABLED_KEY] ?: false }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||||
|
store.edit { it[SPECIAL_DATES_ENABLED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The date types the user wants mirrored (default: all three). */
|
||||||
|
val specialDatesTypes: Flow<Set<SpecialDateType>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.filterTo(mutableSetOf()) { prefs[typeEnabledKey(it)] ?: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||||
|
store.edit { it[typeEnabledKey(type)] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The managed calendar id per type, for the ones that currently exist. */
|
||||||
|
val specialDatesCalendars: Flow<Map<SpecialDateType, Long>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.mapNotNull { type ->
|
||||||
|
prefs[calendarIdKey(type)]?.let { type to it }
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every managed calendar id — the editor locks title/date/recurrence for their events. */
|
||||||
|
val managedCalendarIds: Flow<Set<Long>> = specialDatesCalendars.map { it.values.toSet() }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesCalendarId(type: SpecialDateType, calendarId: Long?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (calendarId == null) prefs.remove(calendarIdKey(type)) else prefs[calendarIdKey(type)] = calendarId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The title template per type ("{name}'s birthday"); an empty string means
|
||||||
|
* unset, so the settings screen can seed the localized default. `{name}` is
|
||||||
|
* the contact name, `{age}` the age at the upcoming date (empty when the
|
||||||
|
* birth year is unknown or [specialDatesShowAge] is off).
|
||||||
|
*/
|
||||||
|
val specialDatesTitleTemplates: Flow<Map<SpecialDateType, String>> = store.data.map { prefs ->
|
||||||
|
SpecialDateType.entries.associateWith { prefs[titleTemplateKey(it)].orEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||||
|
store.edit { it[titleTemplateKey(type)] = template }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
|
||||||
|
val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||||
|
store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of the last mirror sync, for the settings status line. */
|
||||||
|
val specialDatesStatus: Flow<SpecialDatesStatus> = store.data.map { prefs ->
|
||||||
|
SpecialDatesStatus(
|
||||||
|
lastRun = prefs[SPECIAL_DATES_LAST_RUN_KEY] ?: 0L,
|
||||||
|
stalled = prefs[SPECIAL_DATES_STALLED_KEY]
|
||||||
|
?.let { name -> SpecialDatesStalledReason.entries.firstOrNull { it.name == name } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a sync outcome; [stalled] non-null marks the feature paused (e.g. permission revoked). */
|
||||||
|
suspend fun recordSpecialDatesRun(atMillis: Long, stalled: SpecialDatesStalledReason?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[SPECIAL_DATES_LAST_RUN_KEY] = atMillis
|
||||||
|
if (stalled == null) prefs.remove(SPECIAL_DATES_STALLED_KEY) else prefs[SPECIAL_DATES_STALLED_KEY] = stalled.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Epoch millis of the last foreground-triggered sync, to debounce ON_RESUME. */
|
||||||
|
val specialDatesLastForegroundSync: Flow<Long> =
|
||||||
|
store.data.map { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] ?: 0L }
|
||||||
|
|
||||||
|
suspend fun setSpecialDatesLastForegroundSync(atMillis: Long) {
|
||||||
|
store.edit { it[SPECIAL_DATES_LAST_FG_SYNC_KEY] = atMillis }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun typeEnabledKey(type: SpecialDateType) =
|
||||||
|
booleanPreferencesKey("special_dates_type_${type.name}")
|
||||||
|
|
||||||
|
private fun calendarIdKey(type: SpecialDateType) =
|
||||||
|
longPreferencesKey("special_dates_calendar_${type.name}")
|
||||||
|
|
||||||
|
private fun titleTemplateKey(type: SpecialDateType) =
|
||||||
|
stringPreferencesKey("special_dates_title_${type.name}")
|
||||||
|
|
||||||
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(',')
|
||||||
@@ -434,9 +628,47 @@ class SettingsPrefs @Inject constructor(
|
|||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a plain comma-joined view order, completed to every implemented view. */
|
||||||
|
private fun parseViewOrder(stored: String?): List<CalendarView> =
|
||||||
|
completeViewOrder(
|
||||||
|
stored?.split(',').orEmpty()
|
||||||
|
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
|
||||||
|
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
|
||||||
|
if (stored == null) return QuickSwitchConfig.Default
|
||||||
|
val parsed = stored.split(',').mapNotNull { raw ->
|
||||||
|
val token = raw.trim()
|
||||||
|
val disabled = token.startsWith("!")
|
||||||
|
val name = if (disabled) token.drop(1) else token
|
||||||
|
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
|
||||||
|
}
|
||||||
|
val order = completeViewOrder(parsed.map { it.first })
|
||||||
|
// Only views explicitly stored disabled are excluded; anything appended
|
||||||
|
// (a view added in a later release) defaults into the cycle.
|
||||||
|
val disabled = parsed.filter { it.second }.map { it.first }.toSet()
|
||||||
|
return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeQuickSwitch(config: QuickSwitchConfig): String =
|
||||||
|
completeViewOrder(config.order).joinToString(",") { view ->
|
||||||
|
if (view in config.enabled) view.name else "!${view.name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the given order (de-duplicated), then append any views it omits. */
|
||||||
|
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||||
|
val ordered = seen.distinct()
|
||||||
|
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
||||||
|
}
|
||||||
|
|
||||||
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 BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||||
|
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
||||||
|
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
||||||
|
internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
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")
|
||||||
@@ -445,7 +677,10 @@ class SettingsPrefs @Inject constructor(
|
|||||||
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 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 DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
@@ -479,9 +714,30 @@ class SettingsPrefs @Inject constructor(
|
|||||||
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||||
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||||
const val MIN_BACKUP_INTERVAL = 30L
|
const val MIN_BACKUP_INTERVAL = 30L
|
||||||
|
|
||||||
|
internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled")
|
||||||
|
internal val SPECIAL_DATES_SHOW_YEAR_KEY = booleanPreferencesKey("special_dates_show_year")
|
||||||
|
internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
|
||||||
|
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
|
||||||
|
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =
|
||||||
|
longPreferencesKey("special_dates_last_foreground_sync")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot of the special-dates mirror's last outcome (see [SettingsPrefs.specialDatesStatus]). */
|
||||||
|
data class SpecialDatesStatus(
|
||||||
|
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||||
|
val lastRun: Long,
|
||||||
|
/** Non-null when the mirror is paused and why; null when healthy. */
|
||||||
|
val stalled: SpecialDatesStalledReason?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Why the special-dates mirror is paused. */
|
||||||
|
enum class SpecialDatesStalledReason {
|
||||||
|
/** READ_CONTACTS was revoked in system settings after the feature was enabled. */
|
||||||
|
PermissionRevoked,
|
||||||
|
}
|
||||||
|
|
||||||
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||||
data class BackupStatus(
|
data class BackupStatus(
|
||||||
/** Epoch millis of the last run, or 0 if it has never run. */
|
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||||
@@ -490,16 +746,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 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
|
||||||
@@ -524,21 +770,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"
|
||||||
|
|
||||||
@@ -563,8 +794,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
|
||||||
@@ -580,17 +817,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
|
|
||||||
id to parts[1].toReminderList()
|
|
||||||
}.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,16 +17,28 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [this] alert belongs to a calendar the user disabled in-app, so its
|
||||||
|
* reminder must be suppressed (mirroring the event filtering in
|
||||||
|
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
|
||||||
|
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
|
||||||
|
* never treated as disabled. This is the one predicate the disabled-calendar
|
||||||
|
* gate is built from: [postableAlerts] here and the choke point in
|
||||||
|
* [ReminderNotifier.post] both use it.
|
||||||
|
*/
|
||||||
|
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
|
||||||
|
calendarId != 0L && calendarId in disabledCalendarIds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The due alerts that should actually surface as notifications: everything
|
* The due alerts that should actually surface as notifications: everything
|
||||||
* except alerts whose calendar the user has disabled in-app (mirroring the
|
* except alerts whose calendar the user has disabled in-app. The caller still
|
||||||
* event filtering in CalendarRepositoryImpl). The caller still marks the full
|
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
||||||
* due set fired, so suppressed alerts are not re-broadcast by the provider.
|
* provider.
|
||||||
*/
|
*/
|
||||||
internal fun postableAlerts(
|
internal fun postableAlerts(
|
||||||
due: List<ReminderAlert>,
|
due: List<ReminderAlert>,
|
||||||
disabledCalendarIds: Set<Long>,
|
disabledCalendarIds: Set<Long>,
|
||||||
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
|
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||||
@@ -45,6 +57,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
|||||||
@Inject lateinit var notifier: ReminderNotifier
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||||
|
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||||
@@ -60,11 +73,16 @@ class EventReminderReceiver : BroadcastReceiver() {
|
|||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val due = alertStore.dueAlerts(now)
|
val due = alertStore.dueAlerts(now)
|
||||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||||
|
val postable = postableAlerts(due, disabled)
|
||||||
// Suppress reminders for disabled calendars, but still mark
|
// Suppress reminders for disabled calendars, but still mark
|
||||||
// every due alert fired so the provider stops re-broadcasting
|
// every due alert fired so the provider stops re-broadcasting
|
||||||
// the suppressed ones.
|
// the suppressed ones. Stash those suppressed alerts so
|
||||||
postableAlerts(due, disabled).forEach { notifier.post(it) }
|
// re-enabling their calendar can recover them (they would
|
||||||
|
// otherwise stay STATE_FIRED forever with no re-scan).
|
||||||
|
postable.forEach { notifier.post(it) }
|
||||||
alertStore.markFired(due.map { it.alertId }, now)
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
|
suppressedStore.stash(due - postable.toSet(), now)
|
||||||
|
suppressedStore.purgeExpired(now)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pendingResult.finish()
|
pendingResult.finish()
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.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
|
||||||
@@ -33,6 +38,7 @@ import javax.inject.Singleton
|
|||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
private val calendarPrefs: CalendarPrefs,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
@@ -44,17 +50,31 @@ class ReminderNotifier @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun post(alert: ReminderAlert) {
|
suspend fun post(alert: ReminderAlert) {
|
||||||
|
// The single choke point for the disabled-calendar gate: it covers both
|
||||||
|
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
||||||
|
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
||||||
|
// longer notifies — without either receiver duplicating the check.
|
||||||
|
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
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,130 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.MutablePreferences
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Still relevant while the event has not ended: a reminder for an event that is
|
||||||
|
* already over is pointless to re-surface. Falls back to the begin time when the
|
||||||
|
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
|
||||||
|
*/
|
||||||
|
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
||||||
|
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local stash of reminder alerts that fired while their calendar was disabled
|
||||||
|
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
|
||||||
|
* (so the provider stops re-broadcasting the suppressed ones), which would
|
||||||
|
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
|
||||||
|
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
|
||||||
|
* the user re-enables the calendar before the event is over.
|
||||||
|
*
|
||||||
|
* Persisted in the shared preferences DataStore as a set of self-describing
|
||||||
|
* strings (one per alert); the stash never reaches a screen, so there is no
|
||||||
|
* domain model. Entries whose event has already ended are dropped on the next
|
||||||
|
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SuppressedReminderStore @Inject constructor(
|
||||||
|
private val store: DataStore<Preferences>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
|
||||||
|
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
|
||||||
|
if (alerts.isEmpty()) return
|
||||||
|
store.edit { prefs ->
|
||||||
|
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
|
||||||
|
alerts.forEach { byId[it.alertId] = it }
|
||||||
|
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove and return the still-relevant stashed alerts belonging to any of
|
||||||
|
* [calendarIds]; drops expired entries for every calendar in passing.
|
||||||
|
*/
|
||||||
|
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
|
||||||
|
val recovered = mutableListOf<ReminderAlert>()
|
||||||
|
store.edit { prefs ->
|
||||||
|
val kept = decodeAll(prefs).filter { alert ->
|
||||||
|
when {
|
||||||
|
!alert.isRelevantAt(nowMillis) -> false // expired: drop
|
||||||
|
alert.calendarId in calendarIds -> { recovered += alert; false }
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefs.putStash(kept)
|
||||||
|
}
|
||||||
|
return recovered
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
|
||||||
|
suspend fun purgeExpired(nowMillis: Long) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
|
||||||
|
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
|
||||||
|
|
||||||
|
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
|
||||||
|
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
|
||||||
|
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY = stringSetPreferencesKey("suppressed_reminders")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One stash entry as a delimited string. The '|' separator is safe because every
|
||||||
|
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
|
||||||
|
// a null location is stored as a distinct sentinel that Base64 also never yields.
|
||||||
|
private const val FIELD_SEP = "|"
|
||||||
|
private const val NULL_LOCATION = "-"
|
||||||
|
|
||||||
|
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
|
||||||
|
alert.alertId.toString(),
|
||||||
|
alert.eventId.toString(),
|
||||||
|
alert.calendarId.toString(),
|
||||||
|
alert.beginMillis.toString(),
|
||||||
|
alert.endMillis.toString(),
|
||||||
|
if (alert.isAllDay) "1" else "0",
|
||||||
|
alert.title.toBase64(),
|
||||||
|
alert.location?.toBase64() ?: NULL_LOCATION,
|
||||||
|
).joinToString(FIELD_SEP)
|
||||||
|
|
||||||
|
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
|
||||||
|
internal fun decodeStashEntry(raw: String): ReminderAlert? {
|
||||||
|
val parts = raw.split(FIELD_SEP)
|
||||||
|
if (parts.size != 8) return null
|
||||||
|
return try {
|
||||||
|
ReminderAlert(
|
||||||
|
alertId = parts[0].toLong(),
|
||||||
|
eventId = parts[1].toLong(),
|
||||||
|
calendarId = parts[2].toLong(),
|
||||||
|
beginMillis = parts[3].toLong(),
|
||||||
|
endMillis = parts[4].toLong(),
|
||||||
|
title = parts[6].fromBase64(),
|
||||||
|
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
|
||||||
|
isAllDay = parts[5] == "1",
|
||||||
|
)
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
null
|
||||||
|
} catch (e: IllegalArgumentException) { // bad Base64
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toBase64(): String =
|
||||||
|
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
private fun String.fromBase64(): String =
|
||||||
|
String(Base64.getDecoder().decode(this), Charsets.UTF_8)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two Material 3 typeface roles a user can set independently (issue #19):
|
||||||
|
* [BRAND] drives the display/headline styles (expression), [PLAIN] the
|
||||||
|
* title/body/label styles (readability). Each defaults to the system typeface.
|
||||||
|
*/
|
||||||
|
enum class FontRole { BRAND, PLAIN }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -26,6 +26,14 @@ data class CalendarSource(
|
|||||||
* owns for its own calendars). Always null for synced calendars.
|
* owns for its own calendars). Always null for synced calendars.
|
||||||
*/
|
*/
|
||||||
val description: String? = null,
|
val description: String? = null,
|
||||||
|
/**
|
||||||
|
* A special-dates mirror calendar the app manages (birthdays/anniversaries
|
||||||
|
* from contacts). Its events' title/date/recurrence are owned by the sync,
|
||||||
|
* so it's hidden from the new-event calendar picker and its events lock those
|
||||||
|
* fields in the editor. Recognised by a durable provider marker, so it holds
|
||||||
|
* even after a backup restore clears the app's stored ids.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class EventInstance(
|
data class EventInstance(
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The kinds of contact "special date" Calendula mirrors into local calendars.
|
||||||
|
* Each kind gets its own local calendar, so it inherits per-calendar colour,
|
||||||
|
* visibility and reminder defaults for free. See
|
||||||
|
* docs/design/contact-special-dates.md.
|
||||||
|
*/
|
||||||
|
enum class SpecialDateType {
|
||||||
|
Birthday,
|
||||||
|
Anniversary,
|
||||||
|
|
||||||
|
/** Everything else — a contact "Event" that is neither birthday nor anniversary. */
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dated event read from a device contact (a `ContactsContract` `Event`
|
||||||
|
* row). The [lookupKey] is the stable contact identity used to reconcile the
|
||||||
|
* mirror without duplicating; [year] is null when the contact stored the date
|
||||||
|
* without one (`--MM-dd`), in which case age can't be shown.
|
||||||
|
*/
|
||||||
|
data class ContactSpecialDate(
|
||||||
|
val lookupKey: String,
|
||||||
|
val displayName: String,
|
||||||
|
val type: SpecialDateType,
|
||||||
|
val month: Int,
|
||||||
|
val day: Int,
|
||||||
|
val year: Int?,
|
||||||
|
/** The contact's custom label for a [SpecialDateType.Custom] date, if any. */
|
||||||
|
val label: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The parsed month/day (+ optional year) of a contact date. */
|
||||||
|
data class ContactDateParts(val year: Int?, val month: Int, val day: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fixed leap year to validate and anchor year-less dates against, so that a
|
||||||
|
* `--02-29` birthday is representable (and, once anchored, only recurs in leap
|
||||||
|
* years — matching how the calendar provider expands a yearly Feb-29 series).
|
||||||
|
*/
|
||||||
|
const val YEARLESS_ANCHOR_YEAR = 1972
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `ContactsContract.CommonDataKinds.Event.START_DATE` value into its
|
||||||
|
* calendar parts, or null if it isn't a usable date. Handles the three shapes
|
||||||
|
* seen in the wild:
|
||||||
|
* - full `yyyy-MM-dd` (year known),
|
||||||
|
* - year-less `--MM-dd` (year null),
|
||||||
|
* - compact `yyyyMMdd`.
|
||||||
|
*
|
||||||
|
* A date that names an impossible day (e.g. `1999-02-29`) is rejected. Pure, so
|
||||||
|
* it's unit-tested without a device.
|
||||||
|
*/
|
||||||
|
fun parseContactEventDate(raw: String?): ContactDateParts? {
|
||||||
|
val s = raw?.trim().orEmpty()
|
||||||
|
if (s.isEmpty()) return null
|
||||||
|
|
||||||
|
// Year-less: "--MM-dd" or "--MMdd".
|
||||||
|
if (s.startsWith("--")) {
|
||||||
|
val (m, d) = parseMonthDay(s.substring(2)) ?: return null
|
||||||
|
return validated(null, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.contains('-')) {
|
||||||
|
val parts = s.split('-').filter { it.isNotEmpty() }
|
||||||
|
return when (parts.size) {
|
||||||
|
// "yyyy-MM-dd" — but a leading '-' would have dropped the empty
|
||||||
|
// first part, so require the first token to look like a year.
|
||||||
|
3 -> if (s.startsWith('-')) null else validated(
|
||||||
|
year = parts[0].toIntOrNull() ?: return null,
|
||||||
|
month = parts[1].toIntOrNull() ?: return null,
|
||||||
|
day = parts[2].toIntOrNull() ?: return null,
|
||||||
|
)
|
||||||
|
// Year-less "MM-dd" without the "--" prefix.
|
||||||
|
2 -> validated(
|
||||||
|
year = null,
|
||||||
|
month = parts[0].toIntOrNull() ?: return null,
|
||||||
|
day = parts[1].toIntOrNull() ?: return null,
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact "yyyyMMdd".
|
||||||
|
if (s.length == 8 && s.all { it.isDigit() }) {
|
||||||
|
return validated(
|
||||||
|
year = s.substring(0, 4).toInt(),
|
||||||
|
month = s.substring(4, 6).toInt(),
|
||||||
|
day = s.substring(6, 8).toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "MM-dd" or compact "MMdd". */
|
||||||
|
private fun parseMonthDay(rest: String): Pair<Int, Int>? {
|
||||||
|
if (rest.contains('-')) {
|
||||||
|
val p = rest.split('-').filter { it.isNotEmpty() }
|
||||||
|
if (p.size != 2) return null
|
||||||
|
return (p[0].toIntOrNull() ?: return null) to (p[1].toIntOrNull() ?: return null)
|
||||||
|
}
|
||||||
|
if (rest.length == 4 && rest.all { it.isDigit() }) {
|
||||||
|
return rest.substring(0, 2).toInt() to rest.substring(2, 4).toInt()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm month/day form a real calendar date (validated against the actual
|
||||||
|
* year when known, otherwise the leap anchor so `02-29` survives).
|
||||||
|
*/
|
||||||
|
private fun validated(year: Int?, month: Int, day: Int): ContactDateParts? {
|
||||||
|
if (month !in 1..12 || day !in 1..31) return null
|
||||||
|
val checkYear = year ?: YEARLESS_ANCHOR_YEAR
|
||||||
|
return runCatching { LocalDate.of(checkYear, month, day) }
|
||||||
|
.map { ContactDateParts(year, month, day) }
|
||||||
|
.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix of every managed-event `UID_2445`. Distinguishes mirror events from
|
||||||
|
* user-created ones (which carry a random `<uuid>@calendula` UID), so the sync
|
||||||
|
* only ever reconciles — and never deletes — events it actually owns.
|
||||||
|
*/
|
||||||
|
const val MANAGED_UID_PREFIX = "contact-"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||||
|
* contact date. Stable across syncs (the reconciliation key), namespaced by
|
||||||
|
* type so the same contact's birthday and anniversary never collide, and — when
|
||||||
|
* a [discriminator] is given — by it too, so two Custom dates on one contact
|
||||||
|
* (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering
|
||||||
|
* each other.
|
||||||
|
*/
|
||||||
|
fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String {
|
||||||
|
val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty()
|
||||||
|
return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reconciliation key for this date's mirrored event. Birthdays/anniversaries
|
||||||
|
* are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom]
|
||||||
|
* date adds a discriminator (its label, else its month-day) so distinct custom
|
||||||
|
* dates on one contact don't collapse into a single event.
|
||||||
|
*/
|
||||||
|
fun ContactSpecialDate.managedUid(): String =
|
||||||
|
managedEventUid(type, lookupKey, customDiscriminator())
|
||||||
|
|
||||||
|
private fun ContactSpecialDate.customDiscriminator(): String? =
|
||||||
|
if (type == SpecialDateType.Custom) {
|
||||||
|
label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||||
|
* date when the year is known (so age can be derived), otherwise the month/day
|
||||||
|
* on a fixed leap anchor year so a `--02-29` date stays representable and the
|
||||||
|
* anchor never drifts between syncs.
|
||||||
|
*/
|
||||||
|
fun ContactSpecialDate.anchorDate(): LocalDate =
|
||||||
|
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a title [template] for a contact, substituting `{name}` and `{year}`
|
||||||
|
* (the source year — a birthday's birth year or an anniversary's start year;
|
||||||
|
* empty when [year] is null). Unlike an age, the year is static and correct on
|
||||||
|
* every occurrence of the yearly event. Collapses the whitespace an empty
|
||||||
|
* `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
|
||||||
|
* "Jane's birthday" when no year is known.
|
||||||
|
*/
|
||||||
|
fun renderSpecialDateTitle(template: String, name: String, year: Int?): String =
|
||||||
|
template
|
||||||
|
.replace("{name}", name)
|
||||||
|
.replace("{year}", year?.toString().orEmpty())
|
||||||
|
// Drop an empty "()" left by an unresolved {year}, then tidy spacing.
|
||||||
|
.replace(Regex("""\(\s*\)"""), "")
|
||||||
|
.replace(Regex("""\s+"""), " ")
|
||||||
|
.trim()
|
||||||
@@ -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
|
||||||
@@ -72,6 +72,8 @@ 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 = {},
|
||||||
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
|
||||||
@@ -79,6 +81,11 @@ fun CalendarHost(
|
|||||||
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
||||||
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
||||||
|
|
||||||
|
// View customisation (#24): the quick-switch cycle and drawer order. Both have
|
||||||
|
// sensible non-empty initial values, so they're ready before the first frame.
|
||||||
|
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||||
|
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||||
|
|
||||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
}
|
}
|
||||||
@@ -165,12 +172,28 @@ 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) }
|
||||||
|
// 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) {
|
||||||
|
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
|
||||||
@@ -212,6 +235,17 @@ fun CalendarHost(
|
|||||||
detailKey = key
|
detailKey = key
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
|
is WidgetNavRequest.OpenView -> {
|
||||||
|
// A widget header tap: land on a top-level view with no date
|
||||||
|
// drill-in. Reveal it by dropping any covering overlay, then root
|
||||||
|
// the stack on the target (null → the default home) over the
|
||||||
|
// default home — so backing out returns to the default, then exits.
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
createDateIso = null
|
||||||
|
pendingDayIso = null
|
||||||
|
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
is WidgetNavRequest.Create -> {
|
is WidgetNavRequest.Create -> {
|
||||||
// External "new event" entries (QS tile / launcher shortcut /
|
// External "new event" entries (QS tile / launcher shortcut /
|
||||||
// widget) must land on top of whatever is open — the form overlay
|
// widget) must land on top of whatever is open — the form overlay
|
||||||
@@ -246,7 +280,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 },
|
||||||
@@ -256,10 +290,13 @@ 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,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -269,6 +306,8 @@ fun CalendarHost(
|
|||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -277,14 +316,19 @@ fun CalendarHost(
|
|||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
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,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,7 +420,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
|
||||||
@@ -384,6 +431,7 @@ 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
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = null,
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
|
||||||
|
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
|
||||||
|
.map { it.cycle }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Order of the views in the navigation drawer (#24); every view always shown. */
|
||||||
|
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
@@ -33,6 +35,8 @@ 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 = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -49,6 +53,12 @@ fun RootScreen(
|
|||||||
hasPermission = ContextCompat.checkSelfPermission(
|
hasPermission = ContextCompat.checkSelfPermission(
|
||||||
context, Manifest.permission.READ_CALENDAR
|
context, Manifest.permission.READ_CALENDAR
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
// Refresh the contact special-dates mirror on foreground (the
|
||||||
|
// worker is debounced and no-ops when the feature is off). Gated
|
||||||
|
// on the permission so users without the opt-in never enqueue it.
|
||||||
|
if (context.hasContactsPermission()) {
|
||||||
|
SpecialDatesScheduler.runNow(context, foreground = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lifecycle.addObserver(obs)
|
lifecycle.addObserver(obs)
|
||||||
@@ -76,6 +86,8 @@ fun RootScreen(
|
|||||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
onInsertConsumed = onInsertConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -31,4 +31,13 @@ sealed interface WidgetNavRequest {
|
|||||||
|
|
||||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app rooted on a top-level [view] with no date drill-in — a widget
|
||||||
|
* header tap. A null [view] means "the user's default home view" (the agenda
|
||||||
|
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
|
||||||
|
* default home (the month widget's month/year title → [CalendarView.Month],
|
||||||
|
* issue #18), so backing out returns to the default view, then exits.
|
||||||
|
*/
|
||||||
|
data class OpenView(val view: CalendarView?) : WidgetNavRequest
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,18 +59,19 @@ 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.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.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.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.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.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.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -92,10 +93,13 @@ private val zone = TimeZone.currentSystemDefault()
|
|||||||
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,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -120,6 +124,7 @@ fun AgendaScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = anchor,
|
currentDate = anchor,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -140,7 +145,7 @@ fun AgendaScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
AgendaTopBar(
|
AgendaTopBar(
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -187,6 +192,7 @@ fun AgendaScreen(
|
|||||||
pastDisplay = pastDisplay,
|
pastDisplay = pastDisplay,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
@@ -285,6 +291,7 @@ private fun AgendaContent(
|
|||||||
pastDisplay: PastEventDisplay,
|
pastDisplay: PastEventDisplay,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
@@ -314,6 +321,7 @@ private fun AgendaContent(
|
|||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -329,6 +337,7 @@ private fun AgendaList(
|
|||||||
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(
|
||||||
@@ -338,7 +347,7 @@ 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)
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = day.events,
|
items = day.events,
|
||||||
@@ -348,7 +357,7 @@ private fun AgendaList(
|
|||||||
event = event,
|
event = event,
|
||||||
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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -358,10 +367,16 @@ private fun AgendaList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
private fun AgendaDayHeader(
|
||||||
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpenDay(date) },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = agendaDayLabel(date, today),
|
text = agendaDayLabel(date, today),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -94,24 +91,39 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.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.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
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 de.jeanlucmakiola.floret.components.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 +134,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()
|
||||||
@@ -142,7 +155,7 @@ fun CalendarsScreen(
|
|||||||
sessionKey = editorSession,
|
sessionKey = editorSession,
|
||||||
isNew = editorId == NEW_CALENDAR_ID,
|
isNew = editorId == NEW_CALENDAR_ID,
|
||||||
initialName = editing?.displayName.orEmpty(),
|
initialName = editing?.displayName.orEmpty(),
|
||||||
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||||
initialDescription = editing?.description.orEmpty(),
|
initialDescription = editing?.description.orEmpty(),
|
||||||
onSave = { name, color, description ->
|
onSave = { name, color, description ->
|
||||||
val id = editorId
|
val id = editorId
|
||||||
@@ -168,6 +181,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 +204,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 +233,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 +277,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 +326,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 +343,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 +387,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 +466,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)
|
||||||
@@ -507,7 +645,7 @@ private fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
ColorSwatchRow(
|
ColorSwatchRow(
|
||||||
colors = CALENDAR_COLOR_PALETTE,
|
colors = CalendarColorPalette.all,
|
||||||
selected = color,
|
selected = color,
|
||||||
onSelect = { color = it },
|
onSelect = { color = it },
|
||||||
dark = dark,
|
dark = dark,
|
||||||
@@ -664,8 +802,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 +879,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 +1013,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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
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.reminders.ReminderNotifier
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -45,6 +47,8 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
private val suppressedStore: SuppressedReminderStore,
|
||||||
|
private val notifier: ReminderNotifier,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -103,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()),
|
||||||
@@ -144,7 +148,10 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = prefs.disabledCalendarIds.first()
|
val current = prefs.disabledCalendarIds.first()
|
||||||
val next = if (disabled) current + id else current - id
|
val next = if (disabled) current + id else current - id
|
||||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
if (next != current) {
|
||||||
|
prefs.setDisabledCalendarIds(next)
|
||||||
|
if (!disabled) recoverReminders(setOf(id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +164,29 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = prefs.disabledCalendarIds.first()
|
val current = prefs.disabledCalendarIds.first()
|
||||||
val next = if (disabled) current + ids else current - ids.toSet()
|
val next = if (disabled) current + ids else current - ids.toSet()
|
||||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
if (next != current) {
|
||||||
|
prefs.setDisabledCalendarIds(next)
|
||||||
|
if (!disabled) recoverReminders(current intersect ids.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
|
||||||
|
* still relevant (event not yet over), then drop them from the stash. Runs
|
||||||
|
* after the disabled set is written, so the notifier's own disabled gate lets
|
||||||
|
* them through. Best-effort at re-enable time: it mirrors the receiver gates
|
||||||
|
* (reminders on + postable), and there is no later re-scan, so alerts left
|
||||||
|
* unposted because those gates are closed are simply released.
|
||||||
|
*/
|
||||||
|
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
|
||||||
|
if (reEnabledIds.isEmpty()) return
|
||||||
|
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
|
||||||
|
if (recovered.isNotEmpty() &&
|
||||||
|
settingsPrefs.remindersEnabled.first() &&
|
||||||
|
notifier.canPost()
|
||||||
|
) {
|
||||||
|
recovered.forEach { notifier.post(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,22 +13,8 @@ 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
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
/**
|
|
||||||
* 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,6 +62,7 @@ fun CalendarDrawer(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
|
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
) {
|
) {
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -73,10 +77,10 @@ fun CalendarDrawer(
|
|||||||
DrawerHeader()
|
DrawerHeader()
|
||||||
|
|
||||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
viewOrder.forEachIndexed { index, view ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(view.labelRes),
|
title = stringResource(view.labelRes),
|
||||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
position = positionOf(index, viewOrder.size),
|
||||||
selected = view == currentView,
|
selected = view == currentView,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { Icon(view.icon, contentDescription = null) },
|
leading = { Icon(view.icon, contentDescription = null) },
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
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.Check
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
}
|
||||||
@@ -1,56 +1,26 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.BackEventCompat
|
|
||||||
import androidx.activity.compose.PredictiveBackHandler
|
|
||||||
import androidx.compose.animation.ContentTransform
|
import androidx.compose.animation.ContentTransform
|
||||||
import androidx.compose.animation.EnterTransition
|
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.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 slide: the *fast*
|
||||||
@@ -98,128 +68,3 @@ fun calendarSlideTransition(
|
|||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return slideInHorizontally(spec) { w -> dir * w }
|
||||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
|
|
||||||
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
|
|
||||||
* opacity, so it doubles as its own reduced-motion form.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarFadeThrough(): ContentTransform {
|
|
||||||
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
|
||||||
return fadeIn(fade).togetherWith(fadeOut(fade))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter transition for a vertically-revealed section (expandable rows, inline
|
|
||||||
* fields): height grows from the top while fading in. Under reduced motion the
|
|
||||||
* height growth is dropped, leaving a quick fade.
|
|
||||||
*
|
|
||||||
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
|
|
||||||
* originally inlined in the event edit form, so every expandable surface in the
|
|
||||||
* app reveals the same way.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
|
||||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
|
||||||
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
|
|
||||||
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
|
|
||||||
* motion keeps the fade only.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
|
||||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
|
|
||||||
* app's motion scheme instead of Compose's default spring. Returns a bare
|
|
||||||
* [Modifier] under reduced motion so rows snap into place. Requires the list to
|
|
||||||
* supply stable item keys.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
|
||||||
if (reduceMotion) {
|
|
||||||
Modifier
|
|
||||||
} else {
|
|
||||||
Modifier.animateItem(
|
|
||||||
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
|
||||||
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
|
||||||
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard Android predictive-back transform for a full-screen overlay: as the
|
|
||||||
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
|
||||||
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
|
||||||
* gesture invokes [onBack]; cancelling springs it back.
|
|
||||||
*
|
|
||||||
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
|
||||||
* once and apply the returned [Modifier] to that screen's root so the preview
|
|
||||||
* respects the same back semantics. Under reduced motion the visual preview is
|
|
||||||
* skipped (the back still works); on API < 34 the system delivers no progress,
|
|
||||||
* so it degrades to a plain back.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Modifier.predictiveBack(
|
|
||||||
onBack: () -> Unit,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
reduceMotion: Boolean = rememberReduceMotion(),
|
|
||||||
): Modifier {
|
|
||||||
val progress = remember { Animatable(0f) }
|
|
||||||
var fromLeftEdge by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
PredictiveBackHandler(enabled = enabled) { events ->
|
|
||||||
try {
|
|
||||||
events.collect { event ->
|
|
||||||
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
|
||||||
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
|
||||||
}
|
|
||||||
onBack()
|
|
||||||
progress.snapTo(0f)
|
|
||||||
} catch (_: CancellationException) {
|
|
||||||
progress.animateTo(0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reduceMotion) return this
|
|
||||||
return this.graphicsLayer {
|
|
||||||
val p = progress.value
|
|
||||||
val scale = 1f - 0.1f * p
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
|
|
||||||
shape = RoundedCornerShape(32.dp.toPx() * p)
|
|
||||||
clip = p > 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
|
|||||||
|
|
||||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
|
if (available.isEmpty()) return this
|
||||||
val i = available.indexOf(this)
|
val i = available.indexOf(this)
|
||||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
if (i < 0) return available.first()
|
||||||
return available[(i + 1) % available.size]
|
return available[(i + 1) % available.size]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's customisation of the top-bar quick-switch button (#24): which views
|
||||||
|
* it cycles through ([enabled]) and in what [order]. [order] always lists every
|
||||||
|
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||||
|
* is the subset the pill actually steps through, in [order]. The navigation
|
||||||
|
* drawer keeps its own separate order and always lists every view, so a view
|
||||||
|
* disabled here stays reachable there.
|
||||||
|
*/
|
||||||
|
data class QuickSwitchConfig(
|
||||||
|
val order: List<CalendarView>,
|
||||||
|
val enabled: Set<CalendarView>,
|
||||||
|
) {
|
||||||
|
/** Views the pill steps through, in [order]. */
|
||||||
|
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** All views, in default order, all enabled. */
|
||||||
|
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fewest views that keep the switch meaningful — a "switch" needs at
|
||||||
|
* least two targets, so the settings screen blocks disabling below this.
|
||||||
|
*/
|
||||||
|
const val MIN_ENABLED = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The top-level view back stack (bottom → top): the [default] home view always
|
* The top-level view back stack (bottom → top): the [default] home view always
|
||||||
* sits at the bottom. Pressing back pops one level until only the home view
|
* sits at the bottom. Pressing back pops one level until only the home view
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapping row of round colour swatches; the one matching [selected] is
|
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||||
@@ -67,16 +68,3 @@ fun ColorSwatchRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
|
|
||||||
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
|
|
||||||
0xFFD50000, // red
|
|
||||||
0xFFE67C00, // orange
|
|
||||||
0xFFF6BF26, // amber
|
|
||||||
0xFF33B679, // green
|
|
||||||
0xFF0B8043, // dark green
|
|
||||||
0xFF039BE5, // blue
|
|
||||||
0xFF3F51B5, // indigo
|
|
||||||
0xFF8E24AA, // purple
|
|
||||||
0xFF616161, // graphite
|
|
||||||
).map { it.toInt() }
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,218 +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.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Position of a row within a grouped list, after the Android-15 settings
|
|
||||||
* pattern: a run of rows shares one rounded container, with full corners at the
|
|
||||||
* group's outer edges and small corners between, separated by small gaps.
|
|
||||||
*/
|
|
||||||
enum class Position { Top, Middle, Bottom, Alone }
|
|
||||||
|
|
||||||
/** Maps an index within a group of [count] rows to its [Position]. */
|
|
||||||
fun positionOf(index: Int, count: Int): Position = when {
|
|
||||||
count <= 1 -> Position.Alone
|
|
||||||
index == 0 -> Position.Top
|
|
||||||
index == count - 1 -> Position.Bottom
|
|
||||||
else -> Position.Middle
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
|
|
||||||
* whose title shrinks into the bar (next to the back button) as the content
|
|
||||||
* scrolls. Content is a scrollable column that feeds the toolbar via nested
|
|
||||||
* scroll. Used by Settings and the calendar manager so they share one shell.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun CollapsingScaffold(
|
|
||||||
title: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
snackbarHost: @Composable () -> Unit = {},
|
|
||||||
actions: @Composable RowScope.() -> Unit = {},
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val scrollBehavior =
|
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier
|
|
||||||
.predictiveBack(onBack = onBack)
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
topBar = {
|
|
||||||
LargeTopAppBar(
|
|
||||||
title = { Text(title) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.settings_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = actions,
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = snackbarHost,
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
// 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,
|
|
||||||
leading: @Composable (() -> Unit)? = null,
|
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val interaction = remember { MutableInteractionSource() }
|
|
||||||
val pressed by interaction.collectIsPressedAsState()
|
|
||||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
|
||||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
|
||||||
val shape = when (position) {
|
|
||||||
Position.Alone -> RoundedCornerShape(full)
|
|
||||||
Position.Top -> RoundedCornerShape(
|
|
||||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
|
||||||
)
|
|
||||||
Position.Middle -> RoundedCornerShape(small)
|
|
||||||
Position.Bottom -> RoundedCornerShape(
|
|
||||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val gap = when (position) {
|
|
||||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
|
||||||
Position.Bottom, Position.Alone -> Modifier
|
|
||||||
}
|
|
||||||
val itemColors = if (selected) {
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
} else 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,83 +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,
|
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
|
||||||
imeAction: ImeAction = ImeAction.Default,
|
|
||||||
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
|
||||||
onImeAction: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val resolvedStyle = textStyle.copy(
|
|
||||||
color = if (textStyle.color.isSpecified) {
|
|
||||||
textStyle.color
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
)
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
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,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,189 +1,132 @@
|
|||||||
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.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.SideEffect
|
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.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.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] (collapsing title + back button), so a
|
|
||||||
* picker is visually identical to a Settings sub-page and uses the full width.
|
|
||||||
* [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, 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
|
||||||
* independently, so a default can carry several reminders ("1 week before" *and*
|
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||||
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
|
* "on the day"). Two exclusive checkmark rows sit above the list: an explicit
|
||||||
* default reminder" row above the list; choosing it defers to the global default
|
* "No reminder" (both pickers, so the empty state stays visible and reachable)
|
||||||
* and clears the checked times. With nothing checked and inherit off, the choice
|
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||||
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
|
* defers to the global default. Clearing the last checked time reverts to "Use
|
||||||
* expands an inline number field plus a unit selector to add an arbitrary lead
|
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
||||||
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
|
* wipe the calendar's default) and to explicit [ReminderOverride.None]
|
||||||
|
* 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
|
||||||
|
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||||
|
* back.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
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: the chosen override is authoritative while the
|
// Optimistic local state: once the user edits, the chosen override is
|
||||||
// picker is open, so quick successive toggles compose on each other instead
|
// authoritative while the picker is open, so quick successive toggles compose
|
||||||
// of racing the round-trip through the settings flow (which would drop a
|
// on each other instead of racing the round-trip through the settings flow
|
||||||
// toggle made before the previous write echoes back). Seeded once on open;
|
// (which would drop a toggle made before the previous write echoes back).
|
||||||
// the picker leaves composition on dismiss, so reopening re-reads [selected].
|
|
||||||
var current by remember { mutableStateOf(selected) }
|
var current by remember { mutableStateOf(selected) }
|
||||||
val inherits = current is CalendarReminderOverride.Inherit
|
// Whether the user has toggled anything yet. Until then, [current] keeps
|
||||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
// mirroring [selected]: a picker that first composed against the settings
|
||||||
// Presets plus any selected custom (non-preset) values, each as its own row.
|
// flow's initialValue (empty — reachable after process-death restore straight
|
||||||
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
|
// onto the Notifications page, before the CalendarProvider-gated combine
|
||||||
|
// resolves) then adopts the real stored default instead of persisting empty
|
||||||
|
// on the first tap. After the first edit, local state is authoritative.
|
||||||
|
var userEdited by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(selected) {
|
||||||
|
if (!userEdited) current = selected
|
||||||
|
}
|
||||||
|
val inherits = current is ReminderOverride.Inherit
|
||||||
|
val isNone = current is ReminderOverride.None
|
||||||
|
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
|
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||||
|
// 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.
|
||||||
|
val seenCustom = remember { mutableSetOf<Int>() }
|
||||||
|
seenCustom += selectedMinutes.filter { it !in presets }
|
||||||
|
// Presets plus every custom value seen this session, each as its own row.
|
||||||
|
val rows = presets + seenCustom.sorted()
|
||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
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
|
||||||
current = override
|
current = override
|
||||||
onSelect(override)
|
onSelect(override)
|
||||||
}
|
}
|
||||||
// Emit the override for a new set of minutes: empty collapses to None, which
|
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
|
||||||
// (with inherit unchosen) reads as an explicit "no reminder".
|
|
||||||
fun emit(minutes: List<Int>) {
|
|
||||||
val norm = minutes.distinct().sorted()
|
|
||||||
apply(
|
|
||||||
if (norm.isEmpty()) {
|
|
||||||
CalendarReminderOverride.None
|
|
||||||
} else {
|
|
||||||
CalendarReminderOverride.Minutes(norm)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
fun toggle(minute: Int) =
|
fun toggle(minute: Int) =
|
||||||
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
// Exclusive "use default" choice (per-calendar only): its own group above
|
// Exclusive choices in their own group above the multi-select list, since
|
||||||
// the multi-select list, since inheriting is mutually exclusive with
|
// each is mutually exclusive with picking specific times: "use default"
|
||||||
// picking specific times.
|
// (per-calendar only) and an explicit "no reminder" (both pickers, so the
|
||||||
|
// empty state stays visible and deliberately reachable).
|
||||||
if (allowInherit) {
|
if (allowInherit) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.reminder_use_default),
|
title = stringResource(R.string.reminder_use_default),
|
||||||
position = Position.Alone,
|
position = Position.Top,
|
||||||
selected = inherits,
|
selected = inherits,
|
||||||
trailing = if (inherits) {
|
trailing = if (inherits) {
|
||||||
{ SelectedCheck() }
|
{ SelectedCheck() }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
onClick = { apply(ReminderOverride.Inherit) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.reminder_none),
|
||||||
|
position = if (allowInherit) Position.Bottom else Position.Alone,
|
||||||
|
selected = isNone,
|
||||||
|
trailing = if (isNone) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
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
|
||||||
rows.forEachIndexed { index, minute ->
|
rows.forEachIndexed { index, minute ->
|
||||||
val checked = minute in selectedMinutes
|
val checked = minute in selectedMinutes
|
||||||
@@ -204,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,
|
||||||
@@ -222,6 +165,7 @@ fun ReminderDefaultPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
* row above it (matching the grouped-row system, so the two read as one
|
* row above it (matching the grouped-row system, so the two read as one
|
||||||
@@ -238,56 +182,18 @@ 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. */
|
||||||
@@ -383,8 +289,8 @@ fun AgendaRangePicker(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomDaysEditor(
|
CustomDaysEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -407,41 +313,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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -72,13 +72,14 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
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.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
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.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -116,6 +117,8 @@ fun DayScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -175,6 +178,7 @@ fun DayScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = date,
|
currentDate = date,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -196,7 +200,7 @@ fun DayScreen(
|
|||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
|||||||
@@ -91,13 +91,13 @@ 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.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.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.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.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
|
||||||
@@ -250,6 +250,9 @@ fun EventDetailScreen(
|
|||||||
contentDescription = stringResource(R.string.event_detail_edit),
|
contentDescription = stringResource(R.string.event_detail_edit),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Managed special-dates events have no delete — the sync
|
||||||
|
// would resurrect them; the date is removed at the contact.
|
||||||
|
if (!s.isManaged) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onDeleteClick,
|
onClick = onDeleteClick,
|
||||||
enabled = deleteState != DeleteUiState.Deleting,
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
@@ -260,6 +263,7 @@ fun EventDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ sealed interface EventDetailUiState {
|
|||||||
val calendarName: String?,
|
val calendarName: String?,
|
||||||
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||||
val canModify: Boolean = false,
|
val canModify: Boolean = false,
|
||||||
|
/**
|
||||||
|
* The event belongs to a managed special-dates calendar: its editable
|
||||||
|
* fields (reminders, notes) can still be edited, but delete is hidden —
|
||||||
|
* the sync would just resurrect it. Contact dates are removed at the source.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
) : EventDetailUiState
|
) : EventDetailUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
detail = corrected,
|
detail = corrected,
|
||||||
calendarName = calendar?.displayName,
|
calendarName = calendar?.displayName,
|
||||||
canModify = calendar?.canModifyContents == true,
|
canModify = calendar?.canModifyContents == true,
|
||||||
|
isManaged = calendar?.isManaged == true,
|
||||||
)
|
)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -116,30 +117,35 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
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.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -340,29 +346,29 @@ private fun SaveConflictDialog(
|
|||||||
onDiscard: () -> Unit,
|
onDiscard: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
FullScreenPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_conflict_title),
|
||||||
title = { Text(stringResource(R.string.event_edit_conflict_title)) },
|
onDismiss = onDismiss,
|
||||||
text = {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Text(
|
||||||
Text(stringResource(R.string.event_edit_conflict_body))
|
text = stringResource(R.string.event_edit_conflict_body),
|
||||||
Spacer(Modifier.height(4.dp))
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
OptionCard(
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
label = stringResource(R.string.event_edit_conflict_overwrite),
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
supportingText = stringResource(R.string.event_edit_conflict_overwrite_hint),
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_conflict_overwrite),
|
||||||
|
summary = stringResource(R.string.event_edit_conflict_overwrite_hint),
|
||||||
|
position = positionOf(0, 2),
|
||||||
onClick = onOverwrite,
|
onClick = onOverwrite,
|
||||||
)
|
)
|
||||||
OptionCard(
|
GroupedRow(
|
||||||
label = stringResource(R.string.event_edit_conflict_discard),
|
title = stringResource(R.string.event_edit_conflict_discard),
|
||||||
supportingText = stringResource(R.string.event_edit_conflict_discard_hint),
|
summary = stringResource(R.string.event_edit_conflict_discard_hint),
|
||||||
|
position = positionOf(1, 2),
|
||||||
onClick = onDiscard,
|
onClick = onDiscard,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -418,8 +424,8 @@ private fun OptionalFormSection(
|
|||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
||||||
}
|
}
|
||||||
@@ -435,6 +441,10 @@ private fun EventEditContent(
|
|||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
// The title, date and recurrence are managed by the special-dates sync, so
|
||||||
|
// they're locked here; everything else (reminders, location, notes) is the
|
||||||
|
// user's to edit.
|
||||||
|
val locked = state.isManaged
|
||||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -512,15 +522,23 @@ private fun EventEditContent(
|
|||||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
) {
|
) {
|
||||||
// Title: borderless headline, mirroring the detail screen's title +
|
// Title: borderless headline, mirroring the detail screen's title +
|
||||||
// accent bar instead of a boxed Material text field.
|
// accent bar instead of a boxed Material text field. Multi-line so long
|
||||||
|
// titles wrap instead of scrolling off one line (#33); newlines are
|
||||||
|
// stripped in setTitle, so this stays a single logical line — the Enter
|
||||||
|
// key just has no effect.
|
||||||
InlineField(
|
InlineField(
|
||||||
value = form.title,
|
value = form.title,
|
||||||
onValueChange = viewModel::setTitle,
|
onValueChange = viewModel::setTitle,
|
||||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||||
textStyle = MaterialTheme.typography.headlineMedium
|
textStyle = MaterialTheme.typography.headlineMedium
|
||||||
.copy(fontWeight = FontWeight.SemiBold),
|
.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
singleLine = false,
|
||||||
|
enabled = !locked,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
// Ease the height change as the title wraps to another line
|
||||||
|
// instead of letting the accent bar + cards below jump.
|
||||||
|
.animateContentSizeMotion()
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.focusRequester(titleFocusRequester),
|
.focusRequester(titleFocusRequester),
|
||||||
)
|
)
|
||||||
@@ -531,6 +549,17 @@ private fun EventEditContent(
|
|||||||
.height(3.dp)
|
.height(3.dp)
|
||||||
.background(accent, RoundedCornerShape(2.dp)),
|
.background(accent, RoundedCornerShape(2.dp)),
|
||||||
)
|
)
|
||||||
|
if (locked) {
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.event_edit_managed_hint,
|
||||||
|
selectedCalendar?.displayName.orEmpty(),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
@@ -549,7 +578,11 @@ private fun EventEditContent(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
|
Switch(
|
||||||
|
checked = form.isAllDay,
|
||||||
|
onCheckedChange = viewModel::setAllDay,
|
||||||
|
enabled = !locked,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -561,6 +594,7 @@ private fun EventEditContent(
|
|||||||
locale = locale,
|
locale = locale,
|
||||||
onDateClick = { picker = PickerTarget.StartDate },
|
onDateClick = { picker = PickerTarget.StartDate },
|
||||||
onTimeClick = { picker = PickerTarget.StartTime },
|
onTimeClick = { picker = PickerTarget.StartTime },
|
||||||
|
enabled = !locked,
|
||||||
)
|
)
|
||||||
ScheduleRow(
|
ScheduleRow(
|
||||||
label = stringResource(R.string.event_edit_ends),
|
label = stringResource(R.string.event_edit_ends),
|
||||||
@@ -570,6 +604,7 @@ private fun EventEditContent(
|
|||||||
onDateClick = { picker = PickerTarget.EndDate },
|
onDateClick = { picker = PickerTarget.EndDate },
|
||||||
onTimeClick = { picker = PickerTarget.EndTime },
|
onTimeClick = { picker = PickerTarget.EndTime },
|
||||||
isError = EventFormProblem.EndBeforeStart in state.problems,
|
isError = EventFormProblem.EndBeforeStart in state.problems,
|
||||||
|
enabled = !locked,
|
||||||
)
|
)
|
||||||
if (EventFormProblem.EndBeforeStart in state.problems) {
|
if (EventFormProblem.EndBeforeStart in state.problems) {
|
||||||
Spacer(Modifier.height(2.dp))
|
Spacer(Modifier.height(2.dp))
|
||||||
@@ -583,15 +618,16 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
|
|
||||||
// Calendar card — tap anywhere to pick the target calendar. Editing
|
// Calendar card — tap anywhere to pick the target calendar. Picking a
|
||||||
// keeps the owning calendar (moving events between calendars is a
|
// different one while editing *moves* the event (copy+delete on save,
|
||||||
// sync-adapter minefield; every stock calendar app locks it too).
|
// since CALENDAR_ID can't be updated in place). Managed special-dates
|
||||||
|
// events stay locked: the contact sync owns their calendar.
|
||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.CalendarMonth,
|
icon = Icons.Default.CalendarMonth,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
iconTint = accent,
|
iconTint = accent,
|
||||||
onClick = { showCalendarPicker = true }
|
onClick = { showCalendarPicker = true }
|
||||||
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
|
.takeIf { state.calendars.isNotEmpty() && !state.isManaged },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = selectedCalendar?.displayName
|
text = selectedCalendar?.displayName
|
||||||
@@ -700,7 +736,7 @@ private fun EventEditContent(
|
|||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.Repeat,
|
icon = Icons.Default.Repeat,
|
||||||
iconContentDescription = null,
|
iconContentDescription = null,
|
||||||
onClick = { showRecurrencePicker = true },
|
onClick = if (locked) null else ({ showRecurrencePicker = true }),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -943,7 +979,7 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showCalendarPicker) {
|
if (showCalendarPicker) {
|
||||||
CalendarPickerDialog(
|
CalendarPicker(
|
||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
selectedId = form.calendarId,
|
selectedId = form.calendarId,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
@@ -1030,24 +1066,19 @@ private fun FieldPickerDialog(
|
|||||||
onSelect: (EventFormField) -> Unit,
|
onSelect: (EventFormField) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
FullScreenPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_more_fields),
|
||||||
title = { Text(stringResource(R.string.event_edit_more_fields)) },
|
onDismiss = onDismiss,
|
||||||
text = {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
hiddenFields.forEachIndexed { index, field ->
|
||||||
hiddenFields.forEach { field ->
|
GroupedRow(
|
||||||
OptionCard(
|
title = stringResource(eventFormFieldLabel(field)),
|
||||||
label = stringResource(eventFormFieldLabel(field)),
|
position = positionOf(index, hiddenFields.size),
|
||||||
|
leading = { Icon(imageVector = eventFormFieldIcon(field), contentDescription = null) },
|
||||||
onClick = { onSelect(field) },
|
onClick = { onSelect(field) },
|
||||||
icon = eventFormFieldIcon(field),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
||||||
@@ -1072,26 +1103,39 @@ private fun ReminderPickerDialog(
|
|||||||
?.takeIf { it in 1..999 }
|
?.takeIf { it in 1..999 }
|
||||||
?.let { it * unit.minutesFactor }
|
?.let { it * unit.minutesFactor }
|
||||||
|
|
||||||
AlertDialog(
|
FullScreenPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_add_reminder),
|
||||||
title = { Text(stringResource(R.string.event_edit_add_reminder)) },
|
onDismiss = onDismiss,
|
||||||
text = {
|
actions = {
|
||||||
|
// The quick-pick list adds on tap; only the custom step needs Add.
|
||||||
|
if (customMode) {
|
||||||
|
TextButton(
|
||||||
|
enabled = customMinutes != null && customMinutes !in alreadyChosen,
|
||||||
|
onClick = { customMinutes?.let(onSelect) },
|
||||||
|
) { Text(stringResource(R.string.event_edit_add)) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
if (!customMode) {
|
if (!customMode) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
val presets = REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }
|
||||||
REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }.forEach { minutes ->
|
val rowCount = presets.size + 1
|
||||||
OptionCard(
|
presets.forEachIndexed { index, minutes ->
|
||||||
label = reminderLabel(minutes),
|
GroupedRow(
|
||||||
|
title = reminderLabel(minutes),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
onClick = { onSelect(minutes) },
|
onClick = { onSelect(minutes) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OptionCard(
|
GroupedRow(
|
||||||
label = stringResource(R.string.event_edit_reminder_custom),
|
title = stringResource(R.string.event_edit_reminder_custom),
|
||||||
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
onClick = { customMode = true },
|
onClick = { customMode = true },
|
||||||
labelColor = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
DialogAmountField(
|
DialogAmountField(
|
||||||
value = amountText,
|
value = amountText,
|
||||||
onValueChange = { amountText = it },
|
onValueChange = { amountText = it },
|
||||||
@@ -1105,20 +1149,7 @@ private fun ReminderPickerDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
// The quick-pick list adds on tap; only the custom step needs Add.
|
|
||||||
if (customMode) {
|
|
||||||
TextButton(
|
|
||||||
enabled = customMinutes != null && customMinutes !in alreadyChosen,
|
|
||||||
onClick = { customMinutes?.let(onSelect) },
|
|
||||||
) { Text(stringResource(R.string.event_edit_add)) }
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
|
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
|
||||||
@@ -1188,33 +1219,46 @@ private fun RecurrencePickerDialog(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
AlertDialog(
|
FullScreenPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_detail_recurrence),
|
||||||
title = { Text(stringResource(R.string.event_detail_recurrence)) },
|
onDismiss = onDismiss,
|
||||||
text = {
|
actions = {
|
||||||
|
// The preset list applies on tap; only the custom step needs OK.
|
||||||
|
if (customMode) {
|
||||||
|
TextButton(
|
||||||
|
enabled = customResult != null,
|
||||||
|
onClick = { customResult?.let(onSelect) },
|
||||||
|
) { Text(stringResource(R.string.dialog_ok)) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
if (!customMode) {
|
if (!customMode) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
val rowCount = RecurrenceFreq.entries.size + 2
|
||||||
OptionCard(
|
GroupedRow(
|
||||||
label = stringResource(R.string.event_edit_recurrence_none),
|
title = stringResource(R.string.event_edit_recurrence_none),
|
||||||
onClick = { onSelect(null) },
|
position = positionOf(0, rowCount),
|
||||||
selected = current == null,
|
selected = current == null,
|
||||||
|
onClick = { onSelect(null) },
|
||||||
)
|
)
|
||||||
RecurrenceFreq.entries.forEach { entry ->
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
OptionCard(
|
GroupedRow(
|
||||||
label = stringResource(recurrencePresetLabel(entry)),
|
title = stringResource(recurrencePresetLabel(entry)),
|
||||||
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
position = positionOf(index + 1, rowCount),
|
||||||
selected = isPlainPreset && parsed?.freq == entry,
|
selected = isPlainPreset && parsed?.freq == entry,
|
||||||
|
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
OptionCard(
|
GroupedRow(
|
||||||
label = stringResource(R.string.event_edit_recurrence_custom),
|
title = stringResource(R.string.event_edit_recurrence_custom),
|
||||||
onClick = { customMode = true },
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
selected = current != null && !isPlainPreset,
|
selected = current != null && !isPlainPreset,
|
||||||
labelColor = MaterialTheme.colorScheme.primary,
|
onClick = { customMode = true },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.event_edit_recurrence_every),
|
text = stringResource(R.string.event_edit_recurrence_every),
|
||||||
@@ -1236,45 +1280,50 @@ private fun RecurrencePickerDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (freq == RecurrenceFreq.Weekly) {
|
if (freq == RecurrenceFreq.Weekly) {
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
WeekdayToggleRow(
|
WeekdayToggleRow(
|
||||||
selected = daysMask.toDaySet(),
|
selected = daysMask.toDaySet(),
|
||||||
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
||||||
locale = locale,
|
locale = locale,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.event_edit_recurrence_ends),
|
text = stringResource(R.string.event_edit_recurrence_ends),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
OptionCard(
|
}
|
||||||
label = stringResource(R.string.event_edit_recurrence_end_never),
|
GroupedRow(
|
||||||
onClick = { endMode = RecurrenceEndMode.Never },
|
title = stringResource(R.string.event_edit_recurrence_end_never),
|
||||||
|
position = positionOf(0, 3),
|
||||||
selected = endMode == RecurrenceEndMode.Never,
|
selected = endMode == RecurrenceEndMode.Never,
|
||||||
|
onClick = { endMode = RecurrenceEndMode.Never },
|
||||||
)
|
)
|
||||||
OptionCard(
|
GroupedRow(
|
||||||
label = stringResource(R.string.event_edit_recurrence_end_until),
|
title = stringResource(R.string.event_edit_recurrence_end_until),
|
||||||
onClick = {
|
summary = untilDate?.let {
|
||||||
endMode = RecurrenceEndMode.Until
|
|
||||||
showUntilPicker = true
|
|
||||||
},
|
|
||||||
supportingText = untilDate?.let {
|
|
||||||
remember(it, locale) {
|
remember(it, locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
.withLocale(locale).format(it.toJavaLocalDate())
|
.withLocale(locale).format(it.toJavaLocalDate())
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
position = positionOf(1, 3),
|
||||||
selected = endMode == RecurrenceEndMode.Until,
|
selected = endMode == RecurrenceEndMode.Until,
|
||||||
|
onClick = {
|
||||||
|
endMode = RecurrenceEndMode.Until
|
||||||
|
showUntilPicker = true
|
||||||
|
},
|
||||||
)
|
)
|
||||||
OptionCard(
|
GroupedRow(
|
||||||
label = stringResource(R.string.event_edit_recurrence_end_count),
|
title = stringResource(R.string.event_edit_recurrence_end_count),
|
||||||
onClick = { endMode = RecurrenceEndMode.Count },
|
position = positionOf(2, 3),
|
||||||
selected = endMode == RecurrenceEndMode.Count,
|
selected = endMode == RecurrenceEndMode.Count,
|
||||||
|
onClick = { endMode = RecurrenceEndMode.Count },
|
||||||
)
|
)
|
||||||
if (endMode == RecurrenceEndMode.Count) {
|
if (endMode == RecurrenceEndMode.Count) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
DialogAmountField(
|
DialogAmountField(
|
||||||
value = countText,
|
value = countText,
|
||||||
onValueChange = { countText = it },
|
onValueChange = { countText = it },
|
||||||
@@ -1289,20 +1338,6 @@ private fun RecurrencePickerDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
// The preset list applies on tap; only the custom step needs OK.
|
|
||||||
if (customMode) {
|
|
||||||
TextButton(
|
|
||||||
enabled = customResult != null,
|
|
||||||
onClick = { customResult?.let(onSelect) },
|
|
||||||
) { Text(stringResource(R.string.dialog_ok)) }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (showUntilPicker) {
|
if (showUntilPicker) {
|
||||||
CalendarDatePickerDialog(
|
CalendarDatePickerDialog(
|
||||||
@@ -1586,6 +1621,7 @@ private fun AddGuestInlineCard(
|
|||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
keyboardType = KeyboardType.Email,
|
keyboardType = KeyboardType.Email,
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
onImeAction = { commit() },
|
onImeAction = { commit() },
|
||||||
@@ -1666,24 +1702,14 @@ private fun VisibilityPickerDialog(
|
|||||||
onSelect: (AccessLevel) -> Unit,
|
onSelect: (AccessLevel) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
OptionPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_visibility),
|
||||||
title = { Text(stringResource(R.string.event_edit_visibility)) },
|
options = AccessLevel.entries.toList(),
|
||||||
text = {
|
selected = selected,
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
label = { stringResource(accessLevelLabel(it)) },
|
||||||
AccessLevel.entries.forEach { level ->
|
onSelect = onSelect,
|
||||||
OptionCard(
|
onDismiss = onDismiss,
|
||||||
label = stringResource(accessLevelLabel(level)),
|
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
||||||
onClick = { onSelect(level) },
|
|
||||||
icon = accessLevelIcon(level),
|
|
||||||
selected = level == selected,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1706,11 +1732,21 @@ private fun ColorPickerDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
AlertDialog(
|
FullScreenPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_color),
|
||||||
title = { Text(stringResource(R.string.event_edit_color)) },
|
onDismiss = onDismiss,
|
||||||
text = {
|
actions = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
if (hasExplicitColor) {
|
||||||
|
TextButton(onClick = onClear) {
|
||||||
|
Text(stringResource(R.string.event_edit_color_reset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
if (palette.isNotEmpty()) {
|
if (palette.isNotEmpty()) {
|
||||||
ColorSwatchRow(
|
ColorSwatchRow(
|
||||||
colors = palette.map { it.argb },
|
colors = palette.map { it.argb },
|
||||||
@@ -1723,7 +1759,7 @@ private fun ColorPickerDialog(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ColorSwatchRow(
|
ColorSwatchRow(
|
||||||
colors = CALENDAR_COLOR_PALETTE,
|
colors = CalendarColorPalette.all,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
onSelect = onPickRaw,
|
onSelect = onPickRaw,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
@@ -1737,20 +1773,7 @@ private fun ColorPickerDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
dismissButton = if (hasExplicitColor) {
|
|
||||||
{
|
|
||||||
TextButton(onClick = onClear) {
|
|
||||||
Text(stringResource(R.string.event_edit_color_reset))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
|
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
|
||||||
@@ -1858,6 +1881,7 @@ private fun InlineField(
|
|||||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||||
singleLine: Boolean = true,
|
singleLine: Boolean = true,
|
||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1871,7 +1895,9 @@ private fun InlineField(
|
|||||||
textStyle = textStyle,
|
textStyle = textStyle,
|
||||||
singleLine = singleLine,
|
singleLine = singleLine,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
|
enabled = enabled,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1885,6 +1911,7 @@ private fun ScheduleRow(
|
|||||||
onDateClick: () -> Unit,
|
onDateClick: () -> Unit,
|
||||||
onTimeClick: () -> Unit,
|
onTimeClick: () -> Unit,
|
||||||
isError: Boolean = false,
|
isError: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val dateFormat = remember(locale) {
|
val dateFormat = remember(locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
@@ -1894,11 +1921,12 @@ private fun ScheduleRow(
|
|||||||
timeOfDayFormatter(use24Hour, locale)
|
timeOfDayFormatter(use24Hour, locale)
|
||||||
}
|
}
|
||||||
// Tappable values read as links (primary), like the location on the
|
// Tappable values read as links (primary), like the location on the
|
||||||
// detail screen; errors flip them to the error colour.
|
// detail screen; errors flip them to the error colour; a locked (managed)
|
||||||
val valueColor = if (isError) {
|
// row dims to a plain, non-tappable value.
|
||||||
MaterialTheme.colorScheme.error
|
val valueColor = when {
|
||||||
} else {
|
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
MaterialTheme.colorScheme.primary
|
isError -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.primary
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -1915,7 +1943,7 @@ private fun ScheduleRow(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable(onClick = onDateClick)
|
.clickable(enabled = enabled, onClick = onDateClick)
|
||||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
)
|
)
|
||||||
if (!isAllDay) {
|
if (!isAllDay) {
|
||||||
@@ -1924,41 +1952,35 @@ private fun ScheduleRow(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = valueColor,
|
color = valueColor,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clickable(onClick = onTimeClick)
|
.clickable(enabled = enabled, onClick = onTimeClick)
|
||||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen calendar picker: a scrollable list of every writable calendar,
|
||||||
|
* grouped under its owning account like the visibility filter and calendar
|
||||||
|
* manager. Replaces the former fixed-height [AlertDialog], which clipped the
|
||||||
|
* list past ~9 rows so accounts with many calendars had unreachable entries
|
||||||
|
* (#29). Selecting a row calls [onSelect]; the caller closes the picker.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CalendarPickerDialog(
|
private fun CalendarPicker(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
FullScreenPicker(
|
||||||
AlertDialog(
|
title = stringResource(R.string.event_detail_calendar),
|
||||||
onDismissRequest = onDismiss,
|
onDismiss = onDismiss,
|
||||||
title = { Text(stringResource(R.string.event_detail_calendar)) },
|
) {
|
||||||
text = {
|
CalendarPickerGroups(
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
calendars = calendars,
|
||||||
calendars.forEach { calendar ->
|
selectedId = selectedId,
|
||||||
OptionCard(
|
onSelect = onSelect,
|
||||||
label = calendar.displayName,
|
|
||||||
onClick = { onSelect(calendar.id) },
|
|
||||||
icon = Icons.Default.CalendarMonth,
|
|
||||||
// The calendar's own colour carries its identity.
|
|
||||||
iconTint = pastelize(calendar.color, dark),
|
|
||||||
supportingText = calendar.accountName.takeIf { it.isNotBlank() },
|
|
||||||
selected = calendar.id == selectedId,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
|||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
val hiddenFields: List<EventFormField> = 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,
|
||||||
|
/**
|
||||||
|
* True when the event's calendar is a contact special-dates calendar the app
|
||||||
|
* manages. While editing such an event the title, date and recurrence are
|
||||||
|
* locked (sync overwrites them); reminders, location and notes stay editable.
|
||||||
|
*/
|
||||||
|
val isManaged: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Whether to focus the title and raise the keyboard when this is a fresh
|
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||||
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||||
|
|||||||
@@ -122,18 +122,24 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val autofocusTitle: Boolean,
|
val autofocusTitle: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Every calendar the provider exposes; the source for both lists below. */
|
||||||
|
private val allCalendars: Flow<List<CalendarSource>> =
|
||||||
|
repository.calendars().catch { emit(emptyList()) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writable calendars — the only valid event targets. Disabled calendars are
|
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||||
* excluded, so you can't create into a calendar you've removed from the app;
|
* excluded, so you can't create into a calendar you've removed from the app;
|
||||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||||
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||||
|
* Managed special-dates calendars are excluded too: their events are owned by
|
||||||
|
* the contact sync, which would delete any user event created there.
|
||||||
*/
|
*/
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||||
repository.calendars(),
|
allCalendars,
|
||||||
prefs.disabledCalendarIds,
|
prefs.disabledCalendarIds,
|
||||||
) { calendars, disabled ->
|
) { calendars, disabled ->
|
||||||
calendars.filter { it.canModifyContents && it.id !in disabled }
|
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
||||||
}.catch { emit(emptyList()) }
|
}
|
||||||
|
|
||||||
/** The target calendar id, resolved exactly as the form shows it. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||||
@@ -165,21 +171,33 @@ class EventEditViewModel @Inject constructor(
|
|||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
) { local, external, palette ->
|
allCalendars,
|
||||||
|
) { local, external, palette, allCalendars ->
|
||||||
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 } }
|
||||||
?: external.writable.firstOrNull()?.id
|
?: external.writable.firstOrNull()?.id
|
||||||
val resolved = form.copy(calendarId = resolvedId)
|
val resolved = form.copy(calendarId = resolvedId)
|
||||||
|
val resolvedCalendar = allCalendars.firstOrNull { it.id == resolvedId }
|
||||||
|
// Editing an event in a managed calendar locks its synced fields. Keyed
|
||||||
|
// off the calendar's durable marker, not a stored id, so it holds after a
|
||||||
|
// backup restore too.
|
||||||
|
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
||||||
|
// The picker offers writable calendars only; when editing a managed event
|
||||||
|
// its own (excluded) calendar is added back so the row still names it.
|
||||||
|
val pickerCalendars =
|
||||||
|
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||||
|
else external.writable
|
||||||
val visibleFields = external.defaultFields + local.revealed
|
val visibleFields = external.defaultFields + local.revealed
|
||||||
EventEditUiState(
|
EventEditUiState(
|
||||||
form = resolved,
|
form = resolved,
|
||||||
calendars = external.writable,
|
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 = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
|
isManaged = isManaged,
|
||||||
autofocusTitle = external.autofocusTitle,
|
autofocusTitle = external.autofocusTitle,
|
||||||
// A modified-occurrence exception can't carry its own rule, so
|
// A modified-occurrence exception can't carry its own rule, so
|
||||||
// the scope dialog drops "only this event" after a rule change.
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
@@ -318,7 +336,11 @@ 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) {
|
||||||
@@ -417,7 +439,15 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_saveState.value = SaveUiState.Saved
|
_saveState.value = SaveUiState.Saved
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (target != null && target.original.rrule != null) {
|
// 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,
|
||||||
|
// notes, location) live on the series row — never offer the scope dialog,
|
||||||
|
// which would split the series into an exception the sync then reverts.
|
||||||
|
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
|
||||||
_saveState.value = SaveUiState.AwaitingScope
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -472,6 +502,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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,22 +68,25 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
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.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.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.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
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 kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
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
|
||||||
@@ -98,6 +101,8 @@ fun MonthScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -105,6 +110,7 @@ fun MonthScreen(
|
|||||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
|
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
||||||
// The instant before which an event counts as completed, or null when dimming
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
// screen while the setting is off (it stays null regardless of the tick).
|
// screen while the setting is off (it stays null regardless of the tick).
|
||||||
@@ -157,6 +163,7 @@ fun MonthScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = LocalDate(month.year, month.month, 1),
|
currentDate = LocalDate(month.year, month.month, 1),
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -178,7 +185,7 @@ fun MonthScreen(
|
|||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
month = month,
|
month = month,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -207,11 +214,12 @@ fun MonthScreen(
|
|||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
WeekdayHeader(weekStart = weekStart)
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
MonthContent(
|
MonthContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
@@ -227,6 +235,7 @@ fun MonthScreen(
|
|||||||
private fun MonthContent(
|
private fun MonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@@ -272,6 +281,7 @@ private fun MonthContent(
|
|||||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is MonthUiState.Success -> MonthGrid(
|
is MonthUiState.Success -> MonthGrid(
|
||||||
state = s,
|
state = s,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -321,7 +331,7 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekdayHeader(weekStart: DayOfWeek) {
|
private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val days = remember(weekStart, locale) {
|
val days = remember(weekStart, locale) {
|
||||||
(0 until 7).map { offset ->
|
(0 until 7).map { offset ->
|
||||||
@@ -333,6 +343,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
|
// Reserve the gutter so the weekday labels stay over their day columns.
|
||||||
|
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||||
days.forEach { dow ->
|
days.forEach { dow ->
|
||||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||||
@@ -350,6 +362,9 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
|
|||||||
|
|
||||||
private val EVENT_ROW_HEIGHT = 20.dp
|
private val EVENT_ROW_HEIGHT = 20.dp
|
||||||
private val DAY_NUMBER_HEIGHT = 22.dp
|
private val DAY_NUMBER_HEIGHT = 22.dp
|
||||||
|
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
|
||||||
|
* seats a one- or two-digit week number in a full-height tonal pill. */
|
||||||
|
private val WEEK_NUMBER_GUTTER = 40.dp
|
||||||
private val DAY_NUMBER_GAP = 4.dp
|
private val DAY_NUMBER_GAP = 4.dp
|
||||||
private val CELL_TOP_PADDING = 6.dp
|
private val CELL_TOP_PADDING = 6.dp
|
||||||
private val CELL_GAP = 2.dp
|
private val CELL_GAP = 2.dp
|
||||||
@@ -359,6 +374,7 @@ private const val MAX_EVENT_ROWS = 3
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthGrid(
|
private fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -372,6 +388,7 @@ private fun MonthGrid(
|
|||||||
week = week,
|
week = week,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
month = state.month,
|
month = state.month,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -393,6 +410,7 @@ private fun MonthWeekRow(
|
|||||||
week: MonthWeek,
|
week: MonthWeek,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
month: YearMonth,
|
month: YearMonth,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -400,7 +418,23 @@ private fun MonthWeekRow(
|
|||||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
|
|
||||||
BoxWithConstraints(modifier) {
|
Row(modifier) {
|
||||||
|
// Optional calendar-week gutter, sized so the seven day columns below
|
||||||
|
// divide the remaining width — the absolute bar offsets stay correct
|
||||||
|
// because they're measured inside the grid box, not the whole row.
|
||||||
|
if (showWeekNumbers) {
|
||||||
|
WeekNumberGutter(
|
||||||
|
weekStart = week.days.first(),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(WEEK_NUMBER_GUTTER)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
) {
|
||||||
val colW = maxWidth / 7
|
val colW = maxWidth / 7
|
||||||
|
|
||||||
// Per-day background pills — same surfaceContainer rounded surface the
|
// Per-day background pills — same surfaceContainer rounded surface the
|
||||||
@@ -527,6 +561,34 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||||
|
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||||
|
* Week view's badge), with the ISO week number centred like a day number. The
|
||||||
|
* week is computed on the row's first day — the same basis as the Week view — so
|
||||||
|
* the two agree.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
|
||||||
|
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||||
|
val label = stringResource(R.string.week_number_label)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
|
||||||
|
.semantics { contentDescription = "$label $weekNumber" },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = weekNumber.toString(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ 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,
|
||||||
|
)
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
|||||||
@@ -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,23 @@ 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.calendula.ui.common.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.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.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 +102,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 +177,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) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
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
|
||||||
@@ -7,8 +8,11 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
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.QuickSwitchConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -29,6 +33,8 @@ 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,
|
||||||
/** 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). */
|
||||||
@@ -37,6 +43,10 @@ data class SettingsUiState(
|
|||||||
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). */
|
||||||
|
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||||
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
|
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"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||||
@@ -69,4 +79,30 @@ data class SettingsUiState(
|
|||||||
* colour palette (the colour may then not survive their next sync).
|
* colour palette (the colour may then not survive their next sync).
|
||||||
*/
|
*/
|
||||||
val allowColorOnUnsupportedCalendars: Boolean = false,
|
val allowColorOnUnsupportedCalendars: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Ids of the contact special-dates calendars. Their reminders are owned by
|
||||||
|
* the Contact special dates section, so the per-calendar override list links
|
||||||
|
* out to it instead of offering an override here.
|
||||||
|
*/
|
||||||
|
val managedCalendarIds: Set<Long> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the contact special-dates sub-page (issue #15). Exposed as its own
|
||||||
|
* flow rather than folded into [SettingsUiState] (whose combine is already full).
|
||||||
|
* [titleTemplates] is always populated — a blank stored template resolves to the
|
||||||
|
* localized default so the editor never shows an empty field.
|
||||||
|
*/
|
||||||
|
data class SpecialDatesUiState(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
||||||
|
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||||
|
/** The reminder choice per type's managed calendar (applied to all its events). */
|
||||||
|
val reminderChoices: Map<SpecialDateType, ReminderOverride> = emptyMap(),
|
||||||
|
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
||||||
|
val showYear: Boolean = true,
|
||||||
|
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
||||||
|
val stalledPermission: Boolean = false,
|
||||||
|
/** Epoch millis of the last sync, or 0 if it has never run. */
|
||||||
|
val lastRun: Long = 0L,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
@@ -10,22 +11,38 @@ 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.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
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.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.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
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.theme.AppFontSettings
|
||||||
|
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.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.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
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
|
||||||
@@ -35,12 +52,16 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
|
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||||
|
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -86,25 +107,38 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.perCalendarReminderOverride,
|
prefs.perCalendarReminderOverride,
|
||||||
prefs.perCalendarAllDayReminderOverride,
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
writableCalendars,
|
writableCalendars,
|
||||||
) { overrides, allDayOverrides, calendars ->
|
prefs.managedCalendarIds,
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
) { overrides, allDayOverrides, calendars, managedIds ->
|
||||||
|
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.defaultView,
|
prefs.defaultView,
|
||||||
prefs.agendaScreenRange,
|
prefs.agendaScreenRange,
|
||||||
prefs.agendaWidgetRange,
|
prefs.agendaWidgetRange,
|
||||||
prefs.timeFormat,
|
prefs.timeFormat,
|
||||||
prefs.showHourLines,
|
// Two grid-display toggles folded into one flow so they fit this
|
||||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
// group — the outer combine is already at its five-arg limit.
|
||||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
|
||||||
|
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
|
||||||
|
ViewSettings(
|
||||||
|
view, screenRange, widgetRange, timeFormat,
|
||||||
|
showHourLines = gridToggles.first,
|
||||||
|
showWeekNumbers = gridToggles.second,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.agendaShowRangeBar,
|
prefs.agendaShowRangeBar,
|
||||||
prefs.autofocusEventTitle,
|
prefs.autofocusEventTitle,
|
||||||
prefs.pastEventDisplay,
|
prefs.pastEventDisplay,
|
||||||
prefs.dimCompletedEvents,
|
prefs.dimCompletedEvents,
|
||||||
::MiscSettings,
|
// View customisation (#24) folded into one flow so it fits this
|
||||||
),
|
// group — the outer combine is already at its five-arg limit.
|
||||||
|
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
|
||||||
|
ViewCustomization(quickSwitch, drawer)
|
||||||
|
},
|
||||||
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
|
},
|
||||||
) { base, defaults, overrides, views, misc ->
|
) { base, defaults, overrides, views, misc ->
|
||||||
base.copy(
|
base.copy(
|
||||||
defaultView = views.defaultView,
|
defaultView = views.defaultView,
|
||||||
@@ -112,10 +146,13 @@ 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,
|
||||||
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,
|
||||||
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -124,6 +161,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
perCalendarReminderOverride = overrides.timed,
|
perCalendarReminderOverride = overrides.timed,
|
||||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
writableCalendars = overrides.calendars,
|
writableCalendars = overrides.calendars,
|
||||||
|
managedCalendarIds = overrides.managedIds,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -131,6 +169,29 @@ class SettingsViewModel @Inject constructor(
|
|||||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app-wide custom-font choice (issue #19). Its own flow — both this
|
||||||
|
* screen and [MainActivity] (which builds the typography) read it
|
||||||
|
* independently of the main settings combine.
|
||||||
|
*/
|
||||||
|
val fontState: StateFlow<AppFontSettings> = combine(
|
||||||
|
prefs.brandFont,
|
||||||
|
prefs.plainFont,
|
||||||
|
prefs.brandFontStamp,
|
||||||
|
prefs.plainFontStamp,
|
||||||
|
) { brand, plain, brandStamp, plainStamp ->
|
||||||
|
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = AppFontSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Emitted when a picked font file couldn't be read as a font; the screen
|
||||||
|
// surfaces it and the previous selection stays put.
|
||||||
|
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
|
||||||
|
|
||||||
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
|
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
|
||||||
// updateAll calls at once — concurrent calls coalesce around a stale read
|
// updateAll calls at once — concurrent calls coalesce around a stale read
|
||||||
// and can leave the widget on the intermediate value. Held sequentially,
|
// and can leave the widget on the intermediate value. Held sequentially,
|
||||||
@@ -149,6 +210,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val timed: Map<Long, List<Int>>,
|
val timed: Map<Long, List<Int>>,
|
||||||
val allDay: Map<Long, List<Int>>,
|
val allDay: Map<Long, List<Int>>,
|
||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
|
val managedIds: Set<Long>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ViewSettings(
|
private data class ViewSettings(
|
||||||
@@ -157,6 +219,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -164,8 +227,103 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val autofocusEventTitle: Boolean,
|
val autofocusEventTitle: Boolean,
|
||||||
val pastEventDisplay: PastEventDisplay,
|
val pastEventDisplay: PastEventDisplay,
|
||||||
val dimCompletedEvents: Boolean,
|
val dimCompletedEvents: Boolean,
|
||||||
|
val viewCustomization: ViewCustomization,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ViewCustomization(
|
||||||
|
val quickSwitch: QuickSwitchConfig,
|
||||||
|
val drawerOrder: List<CalendarView>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||||
|
val specialDatesState: StateFlow<SpecialDatesUiState> = combine(
|
||||||
|
combine(
|
||||||
|
prefs.specialDatesEnabled,
|
||||||
|
prefs.specialDatesTypes,
|
||||||
|
prefs.specialDatesTitleTemplates,
|
||||||
|
prefs.specialDatesShowYear,
|
||||||
|
prefs.specialDatesStatus,
|
||||||
|
) { enabled, types, templates, showYear, status ->
|
||||||
|
SpecialDatesUiState(
|
||||||
|
enabled = enabled,
|
||||||
|
types = types,
|
||||||
|
// A blank stored template resolves to the localized default so the
|
||||||
|
// editor always shows something sensible.
|
||||||
|
titleTemplates = SpecialDateType.entries.associateWith { type ->
|
||||||
|
specialDatesSpec.resolveTitleTemplate(type, templates)
|
||||||
|
},
|
||||||
|
showYear = showYear,
|
||||||
|
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
||||||
|
lastRun = status.lastRun,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
prefs.specialDatesCalendars,
|
||||||
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { base, calendars, allDayOverrides ->
|
||||||
|
base.copy(
|
||||||
|
// An override is always seeded on creation; present-empty = None.
|
||||||
|
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
||||||
|
allDayOverrides.reminderOverrideFor(calendarId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = SpecialDatesUiState(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the mirror on or off. Enabling schedules the daily job and runs an
|
||||||
|
* immediate sync (which creates the calendars); disabling cancels the job and
|
||||||
|
* tears the managed calendars down. The caller must have READ_CONTACTS granted
|
||||||
|
* before enabling.
|
||||||
|
*/
|
||||||
|
fun setSpecialDatesEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDatesEnabled(enabled)
|
||||||
|
if (enabled) {
|
||||||
|
SpecialDatesScheduler.apply(appContext, enabled = true)
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
} else {
|
||||||
|
SpecialDatesScheduler.apply(appContext, enabled = false)
|
||||||
|
withContext(io) { specialDatesEngine.teardown() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpecialDateTypeEnabled(type: SpecialDateType, enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDateTypeEnabled(type, enabled)
|
||||||
|
// Reconcile now: create the new type's calendar, or delete a disabled one.
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpecialDatesTitleTemplate(type: SpecialDateType, template: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDatesTitleTemplate(type, template.trim())
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSpecialDatesShowYear(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSpecialDatesShowYear(enabled)
|
||||||
|
SpecialDatesScheduler.runNow(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a type's managed-calendar reminder default and apply it to all its
|
||||||
|
* existing events (managed calendars own their reminders calendar-wide).
|
||||||
|
*/
|
||||||
|
fun setSpecialDatesReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||||
|
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kick off an immediate reconcile (the "Sync now" action). */
|
||||||
|
fun syncSpecialDatesNow() = SpecialDatesScheduler.runNow(appContext)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -174,6 +332,35 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setFont(role: FontRole, token: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (role) {
|
||||||
|
FontRole.BRAND -> prefs.setBrandFont(token)
|
||||||
|
FontRole.PLAIN -> prefs.setPlainFont(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a user-picked font file for [role]. Copies and validates it off the
|
||||||
|
* main thread; on success the role switches to the custom font, otherwise the
|
||||||
|
* previous choice is kept and [fontImportFailed] fires.
|
||||||
|
*/
|
||||||
|
fun importCustomFont(role: FontRole, uri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
|
||||||
|
if (ok) {
|
||||||
|
// Bump the stamp so replacing an already-active custom font (same
|
||||||
|
// file, same "custom" token) still breaks font-settings equality
|
||||||
|
// and refreshes the resolved typeface.
|
||||||
|
prefs.bumpCustomFontStamp(role)
|
||||||
|
setFont(role, FONT_CUSTOM_TOKEN)
|
||||||
|
} else {
|
||||||
|
_fontImportFailed.emit(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setWeekStart(pref: WeekStartPref) {
|
fun setWeekStart(pref: WeekStartPref) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setWeekStart(pref)
|
prefs.setWeekStart(pref)
|
||||||
@@ -219,6 +406,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setShowWeekNumbers(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setPastEventDisplay(mode)
|
prefs.setPastEventDisplay(mode)
|
||||||
@@ -247,6 +438,32 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable [view] in the quick-switch cycle. Goes through an atomic
|
||||||
|
* read-modify-write so a concurrent reorder can't clobber this toggle (and
|
||||||
|
* vice versa) by re-serialising a stale snapshot. The MIN_ENABLED floor is
|
||||||
|
* re-checked inside the transform: the screen's own guard reads the
|
||||||
|
* async-echoed snapshot, so two quick disable-taps could both look allowed
|
||||||
|
* yet compose to a below-minimum cycle.
|
||||||
|
*/
|
||||||
|
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.updateQuickSwitch { config ->
|
||||||
|
val next = if (enabled) config.enabled + view else config.enabled - view
|
||||||
|
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reorder the quick-switch views (atomic read-modify-write; keeps the enabled set). */
|
||||||
|
fun setQuickSwitchOrder(order: List<CalendarView>) {
|
||||||
|
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setRemindersEnabled(enabled: Boolean) {
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
@@ -271,11 +488,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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt
Normal file
129
app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontVariation
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Stored token for "use the device default typeface" — the default for both roles. */
|
||||||
|
const val FONT_SYSTEM_TOKEN = "system"
|
||||||
|
|
||||||
|
/** Stored token for "use the font the user loaded from a file" (per role). */
|
||||||
|
const val FONT_CUSTOM_TOKEN = "custom"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A font checked into the app that a user can pick without providing a file
|
||||||
|
* (issue #19). [token] is the stable value persisted in preferences; [family] is
|
||||||
|
* the resolved Compose family. Kept deliberately small and readability-focused.
|
||||||
|
*/
|
||||||
|
enum class BundledFont(
|
||||||
|
val token: String,
|
||||||
|
@StringRes val labelRes: Int,
|
||||||
|
val family: FontFamily,
|
||||||
|
) {
|
||||||
|
/** Purpose-built for low-vision legibility (Braille Institute). */
|
||||||
|
AtkinsonHyperlegible(
|
||||||
|
token = "atkinson_hyperlegible",
|
||||||
|
labelRes = R.string.font_atkinson_hyperlegible,
|
||||||
|
family = FontFamily(
|
||||||
|
Font(R.font.atkinson_hyperlegible_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.atkinson_hyperlegible_bold, FontWeight.Bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
/** A serif alternative; shipped as a variable font, weights via its wght axis. */
|
||||||
|
Lora(
|
||||||
|
token = "lora",
|
||||||
|
labelRes = R.string.font_lora,
|
||||||
|
family = FontFamily(
|
||||||
|
Font(R.font.lora, FontWeight.Normal, variationSettings = weightAxis(400)),
|
||||||
|
Font(R.font.lora, FontWeight.Medium, variationSettings = weightAxis(500)),
|
||||||
|
Font(R.font.lora, FontWeight.Bold, variationSettings = weightAxis(700)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
/** A monospace option. */
|
||||||
|
JetBrainsMono(
|
||||||
|
token = "jetbrains_mono",
|
||||||
|
labelRes = R.string.font_jetbrains_mono,
|
||||||
|
family = FontFamily(
|
||||||
|
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromToken(token: String?): BundledFont? = entries.firstOrNull { it.token == token }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weightAxis(weight: Int) = FontVariation.Settings(FontVariation.weight(weight))
|
||||||
|
|
||||||
|
/** The two roles' current font tokens (see [FontRole]); the app-wide typeface choice. */
|
||||||
|
data class AppFontSettings(
|
||||||
|
val brand: String = FONT_SYSTEM_TOKEN,
|
||||||
|
val plain: String = FONT_SYSTEM_TOKEN,
|
||||||
|
// Per-role custom-font re-import stamp: bumped when the "custom" file is
|
||||||
|
// replaced, so a same-token re-import still breaks equality and refreshes the
|
||||||
|
// resolved family. 0 for existing installs / no import yet (issue #19).
|
||||||
|
val brandStamp: Int = 0,
|
||||||
|
val plainStamp: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a stored [token] for [role] to a Compose [FontFamily], or null to mean
|
||||||
|
* "leave the Material default" (the system typeface). A custom token whose file
|
||||||
|
* is missing or unreadable also resolves to null, so a deleted file degrades to
|
||||||
|
* the system font rather than crashing text layout.
|
||||||
|
*/
|
||||||
|
fun resolveFontFamily(token: String, role: FontRole, context: Context): FontFamily? = when (token) {
|
||||||
|
FONT_SYSTEM_TOKEN -> null
|
||||||
|
FONT_CUSTOM_TOKEN -> customFontFamily(CustomFontStore.file(context, role))
|
||||||
|
else -> BundledFont.fromToken(token)?.family
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun customFontFamily(file: File): FontFamily? =
|
||||||
|
if (file.exists() && file.length() > 0) {
|
||||||
|
runCatching { FontFamily(Font(file)) }.getOrNull()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the app typography from the chosen [brand] and [plain] families. A null
|
||||||
|
* family leaves that role's Material default (system typeface) in place, so the
|
||||||
|
* common "system for both" case returns [CalendulaTypography] untouched. Brand
|
||||||
|
* drives display + headline; plain drives title + body + label (M3's two
|
||||||
|
* typeface roles).
|
||||||
|
*/
|
||||||
|
fun calendulaTypography(brand: FontFamily?, plain: FontFamily?): Typography {
|
||||||
|
if (brand == null && plain == null) return CalendulaTypography
|
||||||
|
val base = CalendulaTypography
|
||||||
|
fun TextStyle.withFamily(family: FontFamily?): TextStyle =
|
||||||
|
if (family != null) copy(fontFamily = family) else this
|
||||||
|
return base.copy(
|
||||||
|
displayLarge = base.displayLarge.withFamily(brand),
|
||||||
|
displayMedium = base.displayMedium.withFamily(brand),
|
||||||
|
displaySmall = base.displaySmall.withFamily(brand),
|
||||||
|
headlineLarge = base.headlineLarge.withFamily(brand),
|
||||||
|
headlineMedium = base.headlineMedium.withFamily(brand),
|
||||||
|
headlineSmall = base.headlineSmall.withFamily(brand),
|
||||||
|
titleLarge = base.titleLarge.withFamily(plain),
|
||||||
|
titleMedium = base.titleMedium.withFamily(plain),
|
||||||
|
titleSmall = base.titleSmall.withFamily(plain),
|
||||||
|
bodyLarge = base.bodyLarge.withFamily(plain),
|
||||||
|
bodyMedium = base.bodyMedium.withFamily(plain),
|
||||||
|
bodySmall = base.bodySmall.withFamily(plain),
|
||||||
|
labelLarge = base.labelLarge.withFamily(plain),
|
||||||
|
labelMedium = base.labelMedium.withFamily(plain),
|
||||||
|
labelSmall = base.labelSmall.withFamily(plain),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,49 +1,37 @@
|
|||||||
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.Typography
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
|
||||||
import androidx.compose.material3.MotionScheme
|
|
||||||
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] /
|
||||||
|
* [CalendulaDarkFallback]) and a [typography] that defaults to
|
||||||
|
* [CalendulaTypography] but is overridable for the user's custom-font choice
|
||||||
|
* (issue #19).
|
||||||
*
|
*
|
||||||
* The Settings screen (later) can override useDynamicColor and themePreference,
|
* The mechanics live in floret-kit's identity module; the look stays here, so a
|
||||||
* but the V1 foundation just follows 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(),
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
|
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
|
typography = typography,
|
||||||
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 = CalendulaTypography,
|
|
||||||
motionScheme = MotionScheme.standard(),
|
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
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.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.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
@@ -88,7 +89,7 @@ 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.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
@@ -97,12 +98,14 @@ 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.components.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
|
||||||
@@ -127,10 +130,13 @@ 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,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -195,6 +201,7 @@ fun WeekScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = weekStart,
|
currentDate = weekStart,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -216,7 +223,7 @@ fun WeekScreen(
|
|||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -245,6 +252,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)
|
||||||
@@ -264,6 +272,7 @@ 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,
|
||||||
) {
|
) {
|
||||||
@@ -338,6 +347,7 @@ private fun WeekContent(
|
|||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -351,6 +361,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()) {
|
||||||
@@ -359,7 +370,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
|
||||||
@@ -423,13 +434,14 @@ 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()
|
||||||
@@ -449,7 +461,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(
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ 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.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
@@ -193,6 +193,8 @@ private fun AgendaHeader() {
|
|||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
// Tap the "Upcoming" title to open the app on the user's default view
|
||||||
|
// (issue #20) — a null target resolves to the default home in the host.
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.widget_agenda_title),
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -200,7 +202,11 @@ private fun AgendaHeader() {
|
|||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier.defaultWeight(),
|
modifier = GlanceModifier
|
||||||
|
.defaultWeight()
|
||||||
|
.clickable(
|
||||||
|
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
resId = R.drawable.ic_widget_refresh,
|
resId = R.drawable.ic_widget_refresh,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ 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.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
@@ -194,6 +194,8 @@ private fun MonthHeader(label: String) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Tap the month/year title to open the app on the month view (issue #18),
|
||||||
|
// rooted over the default home so backing out returns there.
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -204,16 +206,17 @@ private fun MonthHeader(label: String) {
|
|||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.defaultWeight()
|
.defaultWeight()
|
||||||
.clickable(actionRunCallback<ResetMonthAction>()),
|
.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.openViewIntent(context, CalendarView.Month),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
// The "today" button snaps the grid back to the current month in place.
|
||||||
HeaderIcon(
|
HeaderIcon(
|
||||||
resId = R.drawable.ic_widget_today,
|
resId = R.drawable.ic_widget_today,
|
||||||
contentDescription = context.getString(R.string.widget_today),
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
onClick = GlanceModifier.clickable(
|
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||||
actionStartActivity(
|
|
||||||
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
HeaderIcon(
|
HeaderIcon(
|
||||||
resId = R.drawable.ic_widget_chevron_right,
|
resId = R.drawable.ic_widget_chevron_right,
|
||||||
@@ -301,6 +304,15 @@ private fun WeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||||
|
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||||
|
* Shared by every tappable part of a day column so, as in the app, a tap anywhere
|
||||||
|
* on a day opens it; only an event bar on top opts out to open its own detail.
|
||||||
|
*/
|
||||||
|
private fun openDayAction(context: Context, date: LocalDate) =
|
||||||
|
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -308,11 +320,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
|||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.height(DAY_NUMBER_HEIGHT)
|
.height(DAY_NUMBER_HEIGHT)
|
||||||
// Tap a day number to open that day, rooted in the month view so back
|
.clickable(openDayAction(context, date)),
|
||||||
// returns to the month grid.
|
|
||||||
.clickable(
|
|
||||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -339,6 +347,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, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
var col = 0
|
var col = 0
|
||||||
while (col < 7) {
|
while (col < 7) {
|
||||||
@@ -352,7 +361,14 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
if (timed != null) {
|
if (timed != null) {
|
||||||
SpanBar(event = timed, dark = dark, width = colW)
|
SpanBar(event = timed, dark = dark, width = colW)
|
||||||
} else {
|
} else {
|
||||||
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
|
// 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.
|
||||||
|
Box(
|
||||||
|
GlanceModifier
|
||||||
|
.width(colW)
|
||||||
|
.height(LANE_HEIGHT)
|
||||||
|
.clickable(openDayAction(context, week.days[col])),
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
col += 1
|
col += 1
|
||||||
}
|
}
|
||||||
@@ -401,14 +417,20 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
week.days.forEachIndexed { col, date ->
|
week.days.forEachIndexed { col, date ->
|
||||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||||
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||||
|
// The overflow row is part of the day column too: tapping it (whether
|
||||||
|
// it shows "+N" or is blank) opens that day, same as the app.
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
|
modifier = GlanceModifier
|
||||||
|
.width(colW)
|
||||||
|
.height(LANE_HEIGHT)
|
||||||
|
.clickable(openDayAction(context, date)),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
if (hidden > 0) {
|
if (hidden > 0) {
|
||||||
|
|||||||
BIN
app/src/main/res/font/atkinson_hyperlegible_bold.ttf
Normal file
BIN
app/src/main/res/font/atkinson_hyperlegible_bold.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/atkinson_hyperlegible_regular.ttf
Normal file
BIN
app/src/main/res/font/atkinson_hyperlegible_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/jetbrains_mono_bold.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_bold.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/jetbrains_mono_regular.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/lora.ttf
Normal file
BIN
app/src/main/res/font/lora.ttf
Normal file
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Calendula</string>
|
<string name="app_name">Calendula</string>
|
||||||
<string name="app_tagline">Ein moderner Kalender.</string>
|
<string name="app_tagline">Ein moderner Kalender.</string>
|
||||||
|
|
||||||
<string name="state_loading">Lädt…</string>
|
<string name="state_loading">Lädt…</string>
|
||||||
<string name="state_retry">Erneut versuchen</string>
|
<string name="state_retry">Erneut versuchen</string>
|
||||||
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
|
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
|
||||||
@@ -10,10 +10,9 @@
|
|||||||
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
|
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
|
||||||
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
|
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
|
||||||
<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>
|
||||||
@@ -26,7 +25,6 @@
|
|||||||
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
||||||
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
|
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
|
||||||
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
|
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
|
||||||
|
|
||||||
<!-- Monatsansicht (S1) -->
|
<!-- Monatsansicht (S1) -->
|
||||||
<string name="month_prev">Vorheriger Monat</string>
|
<string name="month_prev">Vorheriger Monat</string>
|
||||||
<string name="month_next">Nächster Monat</string>
|
<string name="month_next">Nächster Monat</string>
|
||||||
@@ -35,14 +33,11 @@
|
|||||||
<string name="month_open_menu">Menü öffnen</string>
|
<string name="month_open_menu">Menü öffnen</string>
|
||||||
<string name="month_action_settings">Einstellungen</string>
|
<string name="month_action_settings">Einstellungen</string>
|
||||||
<string name="month_a11y_today_prefix">Heute</string>
|
<string name="month_a11y_today_prefix">Heute</string>
|
||||||
|
|
||||||
<!-- Wochenansicht (S2) -->
|
<!-- Wochenansicht (S2) -->
|
||||||
<string name="week_today_action">Diese Woche</string>
|
<string name="week_today_action">Diese Woche</string>
|
||||||
<string name="week_number_label">KW</string>
|
<string name="week_number_label">KW</string>
|
||||||
|
|
||||||
<!-- Tagesansicht (S3) -->
|
<!-- Tagesansicht (S3) -->
|
||||||
<string name="day_today_action">Heute</string>
|
<string name="day_today_action">Heute</string>
|
||||||
|
|
||||||
<!-- Event-Detail-Screen (S4) -->
|
<!-- Event-Detail-Screen (S4) -->
|
||||||
<string name="event_detail_back">Zurück</string>
|
<string name="event_detail_back">Zurück</string>
|
||||||
<string name="event_detail_edit">Bearbeiten</string>
|
<string name="event_detail_edit">Bearbeiten</string>
|
||||||
@@ -61,7 +56,6 @@
|
|||||||
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
||||||
<string name="dialog_cancel">Abbrechen</string>
|
<string name="dialog_cancel">Abbrechen</string>
|
||||||
<string name="dialog_ok">OK</string>
|
<string name="dialog_ok">OK</string>
|
||||||
|
|
||||||
<!-- Termin-Formular (v1.2 Erstellen) -->
|
<!-- Termin-Formular (v1.2 Erstellen) -->
|
||||||
<string name="event_edit_new_title">Neuer Termin</string>
|
<string name="event_edit_new_title">Neuer Termin</string>
|
||||||
<string name="event_edit_close">Schließen</string>
|
<string name="event_edit_close">Schließen</string>
|
||||||
@@ -94,7 +88,6 @@
|
|||||||
<string name="reminder_unit_weeks">Wochen</string>
|
<string name="reminder_unit_weeks">Wochen</string>
|
||||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||||
<string name="event_edit_visibility">Sichtbarkeit</string>
|
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||||
|
|
||||||
<!-- Termin-Formular — eigene Terminfarbe -->
|
<!-- Termin-Formular — eigene Terminfarbe -->
|
||||||
<string name="event_edit_color">Farbe</string>
|
<string name="event_edit_color">Farbe</string>
|
||||||
<string name="event_edit_color_default">Kalenderfarbe</string>
|
<string name="event_edit_color_default">Kalenderfarbe</string>
|
||||||
@@ -103,7 +96,6 @@
|
|||||||
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
|
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
|
||||||
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
|
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
|
||||||
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
|
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
|
||||||
|
|
||||||
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
||||||
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
||||||
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
||||||
@@ -113,7 +105,6 @@
|
|||||||
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
|
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
|
||||||
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
|
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
|
||||||
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
|
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
|
||||||
|
|
||||||
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
||||||
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
||||||
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
||||||
@@ -156,7 +147,6 @@
|
|||||||
<string name="event_attendee_tentative">Vorläufig</string>
|
<string name="event_attendee_tentative">Vorläufig</string>
|
||||||
<string name="event_attendee_needs_action">Keine Antwort</string>
|
<string name="event_attendee_needs_action">Keine Antwort</string>
|
||||||
<string name="event_attendee_unknown">—</string>
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
|
||||||
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
|
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
|
||||||
<string name="event_detail_reminders">Erinnerungen</string>
|
<string name="event_detail_reminders">Erinnerungen</string>
|
||||||
<string name="event_detail_timezone">Zeitzone</string>
|
<string name="event_detail_timezone">Zeitzone</string>
|
||||||
@@ -196,10 +186,8 @@
|
|||||||
<item quantity="one">%d Stunde</item>
|
<item quantity="one">%d Stunde</item>
|
||||||
<item quantity="other">%d Stunden</item>
|
<item quantity="other">%d Stunden</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|
||||||
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
|
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
|
||||||
<string name="reminder_channel_name">Termin-Erinnerungen</string>
|
<string name="reminder_channel_name">Termin-Erinnerungen</string>
|
||||||
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
|
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
|
||||||
@@ -215,31 +203,26 @@
|
|||||||
<string name="reminder_onboarding_skip_button">Später</string>
|
<string name="reminder_onboarding_skip_button">Später</string>
|
||||||
<string name="reminder_action_snooze">Schlummern</string>
|
<string name="reminder_action_snooze">Schlummern</string>
|
||||||
<string name="reminder_action_dismiss">Verwerfen</string>
|
<string name="reminder_action_dismiss">Verwerfen</string>
|
||||||
|
|
||||||
<!-- View-Switcher (M1) -->
|
<!-- View-Switcher (M1) -->
|
||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
<string name="view_week">Woche</string>
|
<string name="view_week">Woche</string>
|
||||||
<string name="view_day">Tag</string>
|
<string name="view_day">Tag</string>
|
||||||
<string name="view_agenda">Agenda</string>
|
<string name="view_agenda">Agenda</string>
|
||||||
<string name="view_section">Ansicht</string>
|
<string name="view_section">Ansicht</string>
|
||||||
|
|
||||||
<!-- Zu Datum springen (Navigationsleiste) -->
|
<!-- Zu Datum springen (Navigationsleiste) -->
|
||||||
<string name="drawer_jump_to_date">Zu Datum springen</string>
|
<string name="drawer_jump_to_date">Zu Datum springen</string>
|
||||||
|
|
||||||
<!-- Agenda-Ansicht -->
|
<!-- Agenda-Ansicht -->
|
||||||
<string name="agenda_today_action">Heute</string>
|
<string name="agenda_today_action">Heute</string>
|
||||||
<string name="agenda_header_today">Heute</string>
|
<string name="agenda_header_today">Heute</string>
|
||||||
<string name="agenda_header_tomorrow">Morgen</string>
|
<string name="agenda_header_tomorrow">Morgen</string>
|
||||||
<string name="agenda_empty_title">Alles erledigt</string>
|
<string name="agenda_empty_title">Alles erledigt</string>
|
||||||
|
|
||||||
<!-- Terminsuche -->
|
<!-- Terminsuche -->
|
||||||
<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 -->
|
||||||
<string name="widget_agenda_title">Anstehend</string>
|
<string name="widget_agenda_title">Anstehend</string>
|
||||||
<string name="widget_agenda_label">Calendula Agenda</string>
|
<string name="widget_agenda_label">Calendula Agenda</string>
|
||||||
@@ -250,22 +233,19 @@
|
|||||||
<string name="widget_prev_month">Vorheriger Monat</string>
|
<string name="widget_prev_month">Vorheriger Monat</string>
|
||||||
<string name="widget_next_month">Nächster Monat</string>
|
<string name="widget_next_month">Nächster Monat</string>
|
||||||
<string name="widget_today">Heute</string>
|
<string name="widget_today">Heute</string>
|
||||||
|
|
||||||
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
|
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
|
||||||
<string name="shortcut_new_event_short">Neuer Termin</string>
|
<string name="shortcut_new_event_short">Neuer Termin</string>
|
||||||
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
||||||
|
|
||||||
<!-- Schnelleinstellungen-Kachel -->
|
<!-- Schnelleinstellungen-Kachel -->
|
||||||
<string name="qs_tile_new_event_label">Neuer Termin</string>
|
<string name="qs_tile_new_event_label">Neuer Termin</string>
|
||||||
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
|
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
|
||||||
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
|
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
|
||||||
|
|
||||||
<!-- Kalender-Filter (M3) -->
|
<!-- Kalender-Filter (M3) -->
|
||||||
<string name="filter_title">Kalender</string>
|
<string name="filter_title">Kalender</string>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -330,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, Standartansicht, 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>
|
||||||
@@ -343,7 +323,6 @@
|
|||||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||||
<string name="settings_report_problem">Problem melden</string>
|
<string name="settings_report_problem">Problem melden</string>
|
||||||
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
|
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
|
||||||
|
|
||||||
<!-- Calendar manager -->
|
<!-- Calendar manager -->
|
||||||
<string name="calendars_title">Kalender</string>
|
<string name="calendars_title">Kalender</string>
|
||||||
<string name="calendars_local_header">Deine Kalender</string>
|
<string name="calendars_local_header">Deine Kalender</string>
|
||||||
@@ -351,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>
|
||||||
@@ -398,16 +377,101 @@
|
|||||||
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
|
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
|
||||||
<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>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
469
app/src/main/res/values-es/strings.xml
Normal file
469
app/src/main/res/values-es/strings.xml
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="app_tagline">Un calendario moderno.</string>
|
||||||
|
<string name="state_loading">Cargando…</string>
|
||||||
|
<string name="state_retry">Vuelve a intentarlo</string>
|
||||||
|
<string name="state_failure_unknown">Algo salio mal.</string>
|
||||||
|
<string name="state_failure_permission">Se requiere acceso al calendario.</string>
|
||||||
|
<string name="state_failure_permission_action">Permite el acceso</string>
|
||||||
|
<string name="state_failure_no_calendars">No se han configurado calendarios.</string>
|
||||||
|
<string name="state_failure_no_calendars_action">Abrir los ajustes del calendario del sistema</string>
|
||||||
|
<string name="state_failure_provider">No se pudo leer el calendario.</string>
|
||||||
|
<string name="permission_rationale_title">Mira todos sus eventos, a la perfección</string>
|
||||||
|
<string name="permission_rationale_body">Calendula necesita acceso a tu calendario para mostrarte y administrar eventos. Ese es el único permiso que pedirá para comenzar a funcionar. Y nada de información sale de tu dispositivo.</string>
|
||||||
|
<string name="permission_request_button">Conceder el acceso al calendario</string>
|
||||||
|
<string name="permission_denied_title">Acceso a calendario denegado</string>
|
||||||
|
<string name="permission_denied_body">Calendula no puede mostrar eventos sin acceso al calendario. Puedes concederlo en ajustes del sistema.</string>
|
||||||
|
<string name="permission_open_settings_button">Abrir ajustes del sistema</string>
|
||||||
|
<string name="permission_retry_button">Intentarlo de nuevo</string>
|
||||||
|
<string name="permission_benefit_private_title">Permanece en tu dispositivo</string>
|
||||||
|
<string name="permission_benefit_private_body">Tus calendarios se leen localmente y nunca abandonan tu celular.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Todos tus calendarios, juntos</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, local — todo lo sincronizado en el dispositivo aparecerá.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Sin rastreo, jamás</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Sin telemetría, sin analíticas, sin anuncios.</string>
|
||||||
|
<string name="permission_privacy_footnote">Permanece en tu dispositivo, no necesita acceso a internet</string>
|
||||||
|
<string name="month_prev">Mes anterior</string>
|
||||||
|
<string name="month_next">Siguiente mes</string>
|
||||||
|
<string name="month_today_action">Hoy</string>
|
||||||
|
<string name="month_more_actions">Mas acciones</string>
|
||||||
|
<string name="month_open_menu">Abrir menu</string>
|
||||||
|
<string name="month_action_settings">Ajustes</string>
|
||||||
|
<string name="month_a11y_today_prefix">Hoy</string>
|
||||||
|
<string name="week_today_action">Esta semana</string>
|
||||||
|
<string name="week_number_label">Sem</string>
|
||||||
|
<string name="day_today_action">Hoy</string>
|
||||||
|
<string name="event_detail_back">Atras</string>
|
||||||
|
<string name="event_detail_edit">Editar</string>
|
||||||
|
<string name="event_detail_delete">Eliminar</string>
|
||||||
|
<string name="event_detail_share">Compartir</string>
|
||||||
|
<string name="event_share_chooser_title">Compartir evento</string>
|
||||||
|
<string name="event_share_failed">No se pudo compartir el evento.</string>
|
||||||
|
<string name="event_delete_title">¿Eliminar evento?</string>
|
||||||
|
<string name="event_delete_body">El evento se elimino de tu calendario y de todos los dispositivos con los cuales se sincroniza.</string>
|
||||||
|
<string name="event_delete_recurring_title">Eliminar evento recurrente</string>
|
||||||
|
<string name="event_delete_option_occurrence">Solo este evento</string>
|
||||||
|
<string name="event_delete_option_following">Este y todos los eventos siguientes</string>
|
||||||
|
<string name="event_delete_option_series">Todos los eventos en la serie</string>
|
||||||
|
<string name="event_edit_recurring_title">Editar evento recurrente</string>
|
||||||
|
<string name="event_delete_failed">No se pudo eliminar el evento</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula necesita acceso de escritura para eliminar eventos</string>
|
||||||
|
<string name="dialog_cancel">Cancelar</string>
|
||||||
|
<string name="dialog_ok">Okey</string>
|
||||||
|
<string name="event_edit_new_title">Nuevo evento</string>
|
||||||
|
<string name="event_edit_close">Cerrar</string>
|
||||||
|
<string name="event_edit_save">Guardar</string>
|
||||||
|
<string name="event_edit_title_hint">Añadir título</string>
|
||||||
|
<string name="event_edit_starts">Inicio</string>
|
||||||
|
<string name="event_edit_ends">Final</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Termina antes de comenzar</string>
|
||||||
|
<string name="event_edit_error_no_calendar">No hay ningún calendario en donde se pueda escribir disponible</string>
|
||||||
|
<string name="event_edit_save_failed">No se pudo guardar el evento</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula necesita acceso de escritura para crear eventos</string>
|
||||||
|
<string name="event_edit_more_fields">Mas campos</string>
|
||||||
|
<string name="event_edit_add">Añadir</string>
|
||||||
|
<string name="event_edit_add_reminder">Añadir recordatorio</string>
|
||||||
|
<string name="event_edit_remove_reminder">Eliminar recordatorio</string>
|
||||||
|
<string name="event_edit_attendees">Invitados</string>
|
||||||
|
<string name="event_edit_add_guest">Añadir invitados</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Añadir invitados por correo…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Añadir desde contactos</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Seleccionar dirección desde contactos</string>
|
||||||
|
<string name="event_edit_remove_guest">Eliminar invitado</string>
|
||||||
|
<string name="event_edit_attendee_required">Obligatorio</string>
|
||||||
|
<string name="event_edit_attendee_optional">Opcional</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula no envia invitaciones. Tu cuenta de calendario puede enviar correos a los invitados cuando se sincronice.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Guardado en el dispositivo. No se avisa a nadie.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Personalizado</string>
|
||||||
|
<string name="reminder_unit_minutes">minutos</string>
|
||||||
|
<string name="reminder_unit_hours">horas</string>
|
||||||
|
<string name="reminder_unit_days">días</string>
|
||||||
|
<string name="reminder_unit_weeks">semanas</string>
|
||||||
|
<string name="event_edit_availability">Disponibilidad</string>
|
||||||
|
<string name="event_edit_visibility">Visibilidad</string>
|
||||||
|
<string name="event_edit_color">Color</string>
|
||||||
|
<string name="event_edit_color_default">Color del calendario</string>
|
||||||
|
<string name="event_edit_color_custom">Color personalizado</string>
|
||||||
|
<string name="event_edit_color_reset">Reiniciar</string>
|
||||||
|
<string name="event_edit_color_unsupported">No disponible para este calendario</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Este calendario no admite un ser de colores. Puedes permitir colores personalizados para este tipo de calendarios en ajustes.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Este calendario puede eliminar o cambiar el color la próxima vez que se sincronice.</string>
|
||||||
|
<string name="event_edit_conflict_title">El evento fue cambiado por otra fuente</string>
|
||||||
|
<string name="event_edit_conflict_body">Mientras lo editabas, este evento fue cambiado — debido a la sincronización u otra aplicación ¿Que deberia le ocurrir a tus cambios?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Guardar mis cambios</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Sobreescribir el cambio externo en solo los aspectos que he cambiado</string>
|
||||||
|
<string name="event_edit_conflict_discard">Descartar todos mis cambios</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">El evento permanece tal y como esta ahora</string>
|
||||||
|
<string name="event_edit_gone_title">Evento eliminado</string>
|
||||||
|
<string name="event_edit_gone_body">Este evento fue eliminado durante el proceso, en otro dispositivo o aplicación por ejemplo. Tus cambios no pudieron ser guardados.</string>
|
||||||
|
<string name="event_edit_recurrence_none">No se repite</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Personalizado</string>
|
||||||
|
<string name="event_edit_recurrence_every">Cada</string>
|
||||||
|
<string name="recurrence_unit_days">días</string>
|
||||||
|
<string name="recurrence_unit_weeks">semanas</string>
|
||||||
|
<string name="recurrence_unit_months">meses</string>
|
||||||
|
<string name="recurrence_unit_years">años</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Finaliza</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Nunca</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">En una fecha</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Después de cierto numero de veces</string>
|
||||||
|
<string name="event_edit_recurrence_times">veces</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">El evento se deja de repetir antes de que este comience</string>
|
||||||
|
<string name="event_availability_busy">Ocupado</string>
|
||||||
|
<string name="event_access_default">Por defecto</string>
|
||||||
|
<string name="event_access_public">Público</string>
|
||||||
|
<string name="event_detail_all_day">Todo el día</string>
|
||||||
|
<string name="event_detail_calendar">Calendario</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Calendario desconocido</string>
|
||||||
|
<string name="event_detail_location">Ubicación</string>
|
||||||
|
<string name="event_detail_description">Descripción</string>
|
||||||
|
<string name="event_detail_attendees">Participantes</string>
|
||||||
|
<string name="event_detail_recurrence">Recurrencia</string>
|
||||||
|
<string name="agenda_today_action">Hoy</string>
|
||||||
|
<string name="agenda_header_today">Hoy</string>
|
||||||
|
<string name="search_back">Atras</string>
|
||||||
|
<string name="widget_new_event">Nuevo evento</string>
|
||||||
|
<string name="widget_prev_month">Mes anterior</string>
|
||||||
|
<string name="widget_next_month">Siguiente mes</string>
|
||||||
|
<string name="widget_today">Hoy</string>
|
||||||
|
<string name="filter_title">Calendarios</string>
|
||||||
|
<string name="settings_title">Ajustes</string>
|
||||||
|
<string name="settings_back">Atras</string>
|
||||||
|
<string name="agenda_range_day">Hoy</string>
|
||||||
|
<string name="agenda_range_this_week">Esta semana</string>
|
||||||
|
<string name="settings_section_calendars">Calendarios</string>
|
||||||
|
<string name="calendars_title">Calendarios</string>
|
||||||
|
<string name="calendars_color_label">Color</string>
|
||||||
|
<string name="import_close">Cerrar</string>
|
||||||
|
<string name="shortcut_new_event_short">Nuevo evento</string>
|
||||||
|
<string name="qs_tile_new_event_label">Nuevo evento</string>
|
||||||
|
<string name="event_detail_recurring">Repetir el evento</string>
|
||||||
|
<string name="recurrence_daily">Todos los días</string>
|
||||||
|
<string name="recurrence_weekly">Todas las semanas</string>
|
||||||
|
<string name="recurrence_monthly">Todos los meses</string>
|
||||||
|
<string name="recurrence_yearly">Todos los años</string>
|
||||||
|
<string name="recurrence_every_n_days">Cada %1$d días</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Cada %1$d semanas</string>
|
||||||
|
<string name="recurrence_every_n_months">Cada %1$d meses</string>
|
||||||
|
<string name="recurrence_every_n_years">Cada %1$d años</string>
|
||||||
|
<string name="event_detail_not_found">Este evento ya no existe.</string>
|
||||||
|
<string name="event_attendee_accepted">Aceptado</string>
|
||||||
|
<string name="event_attendee_declined">Rechazado</string>
|
||||||
|
<string name="event_attendee_tentative">Tentativo</string>
|
||||||
|
<string name="event_attendee_needs_action">Sin respuesta</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Recordatorios</string>
|
||||||
|
<string name="event_detail_timezone">Zona horaria</string>
|
||||||
|
<string name="event_status_tentative">Tentativo</string>
|
||||||
|
<string name="event_status_cancelled">Cancelado</string>
|
||||||
|
<string name="event_availability_free">Disponible</string>
|
||||||
|
<string name="event_access_private">Privado</string>
|
||||||
|
<string name="event_access_confidential">Confidencial</string>
|
||||||
|
<string name="event_attendee_organizer">Organizador</string>
|
||||||
|
<string name="event_attendee_optional">Opcional</string>
|
||||||
|
<string name="agenda_range_custom">Personalizado…</string>
|
||||||
|
<string name="calendars_backup_header">Respaldo</string>
|
||||||
|
<string name="backup_channel_name">Respaldo</string>
|
||||||
|
<string name="event_detail_self_response">Tu respuesta: %1$s</string>
|
||||||
|
<string name="reminder_at_time">Al la hora del evento</string>
|
||||||
|
<string name="reminder_default">Recordatorio por defecto</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minuto antes</item>
|
||||||
|
<item quantity="many">%d minutos antes</item>
|
||||||
|
<item quantity="other">%d minutos antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d hora antes</item>
|
||||||
|
<item quantity="many">%d horas antes</item>
|
||||||
|
<item quantity="other">%d horas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d día antes</item>
|
||||||
|
<item quantity="many">%d días antes</item>
|
||||||
|
<item quantity="other">%d días antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d semana antes</item>
|
||||||
|
<item quantity="many">%d semanas antes</item>
|
||||||
|
<item quantity="other">%d semanas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minuto</item>
|
||||||
|
<item quantity="many">%d minutos</item>
|
||||||
|
<item quantity="other">%d minutos</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d hora</item>
|
||||||
|
<item quantity="many">%d horas</item>
|
||||||
|
<item quantity="other">%d horas</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d dia</item>
|
||||||
|
<item quantity="many">%d días</item>
|
||||||
|
<item quantity="other">%d días</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d semana</item>
|
||||||
|
<item quantity="many">%d semanas</item>
|
||||||
|
<item quantity="other">%d semanas</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(Sin título)</string>
|
||||||
|
<string name="reminder_channel_name">Recordatorios del evento</string>
|
||||||
|
<string name="reminder_channel_description">Notificaciones a la hora de los recordatorios de tus eventos</string>
|
||||||
|
<string name="reminder_onboarding_title">Nunca te pierdas un evento</string>
|
||||||
|
<string name="reminder_onboarding_body">Android no muestra recordatorios de eventos por su cuenta — una app de calendario tiene que hacerlo. Deja que calendula cumpla ese trabajo.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Recordatorios, enviados</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Cada recordatorio de tus eventos aparece como notificación, justo a tiempo.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">¿Utilizas otra aplicación de calendario?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Si otra aplicación tambien muestra recordatorios, los veras dos veces — desactivalos en esta o la otra aplicación.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Cambialo en cualquier momento</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">El interruptor se encuentra en Ajustes, en la sección de Notificaciones.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Activar recordatorios</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Mas tarde</string>
|
||||||
|
<string name="reminder_action_snooze">Posponer</string>
|
||||||
|
<string name="reminder_action_dismiss">Descartar</string>
|
||||||
|
<string name="view_month">Mes</string>
|
||||||
|
<string name="view_week">Semana</string>
|
||||||
|
<string name="view_day">Día</string>
|
||||||
|
<string name="view_agenda">Agenda</string>
|
||||||
|
<string name="view_section">Vista</string>
|
||||||
|
<string name="drawer_jump_to_date">Ir a la fecha</string>
|
||||||
|
<string name="agenda_header_tomorrow">Mañana</string>
|
||||||
|
<string name="agenda_empty_title">Ya estas al día</string>
|
||||||
|
<string name="search_action">Buscar</string>
|
||||||
|
<string name="search_hint">Buscar eventos</string>
|
||||||
|
<string name="search_clear">Limpiar</string>
|
||||||
|
<string name="search_idle_hint">Buscar eventos por titulo, ubicación o notas.</string>
|
||||||
|
<string name="search_empty">No evento coincide con “%1$s”.</string>
|
||||||
|
<string name="widget_agenda_title">Próximamente</string>
|
||||||
|
<string name="widget_agenda_label">Calendula agenda</string>
|
||||||
|
<string name="widget_month_label">Calendula mes</string>
|
||||||
|
<string name="widget_refresh">Actualizar</string>
|
||||||
|
<string name="widget_needs_permission">Abre Calendula para conceder acceso al calendario</string>
|
||||||
|
<string name="settings_section_appearance">Apariencia</string>
|
||||||
|
<string name="settings_theme">Tema</string>
|
||||||
|
<string name="settings_theme_system">Igual que el sistema</string>
|
||||||
|
<string name="settings_theme_light">Claro</string>
|
||||||
|
<string name="settings_theme_dark">Oscuro</string>
|
||||||
|
<string name="settings_default_view">Vista por defecto</string>
|
||||||
|
<string name="settings_dynamic_color">Color dinámico</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Requiere android 12 o una versión posterior</string>
|
||||||
|
<string name="settings_week_start">La semana empieza en</string>
|
||||||
|
<string name="settings_week_start_auto">Automático</string>
|
||||||
|
<string name="settings_time_format">Formato de la hora</string>
|
||||||
|
<string name="settings_time_format_auto">Automático</string>
|
||||||
|
<string name="settings_time_format_12h">12-horas (2:00 PM)</string>
|
||||||
|
<string name="settings_time_format_24h">24-horas (14:00)</string>
|
||||||
|
<string name="settings_hour_lines">Lineas entre horas</string>
|
||||||
|
<string name="settings_hour_lines_summary">Muestra una linea que separa cada hora en la vista diaria y semanal</string>
|
||||||
|
<string name="settings_dim_completed">Oscurecer eventos completados</string>
|
||||||
|
<string name="settings_dim_completed_summary">Oscurece eventos que ya han terminado en vista semanal y mensual</string>
|
||||||
|
<string name="settings_past_events">Eventos pasados</string>
|
||||||
|
<string name="settings_past_events_show">Mostrar</string>
|
||||||
|
<string name="settings_past_events_dim">Oscurecer</string>
|
||||||
|
<string name="settings_past_events_hide">Ocultar</string>
|
||||||
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
|
<string name="settings_agenda_range">Rango de agenda</string>
|
||||||
|
<string name="settings_agenda_range_hint">Cuanto tiempo abarca la agenda al mostrar eventos.</string>
|
||||||
|
<string name="settings_agenda_widget_range">Rango del widget de agenda</string>
|
||||||
|
<string name="settings_agenda_widget_range_hint">Cuanto tiempo abarca el widget de agenda en la pantalla principal al mostrar eventos.</string>
|
||||||
|
<string name="settings_agenda_range_bar">Barra de tiempo</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">Muestra una barra en la parte superior de la agenda con las fechas mostradas, con un botón para cambiar el rango durante la sesión</string>
|
||||||
|
<string name="agenda_range_this_month">Este mes</string>
|
||||||
|
<string name="agenda_range_week">Próximos 7 días</string>
|
||||||
|
<string name="agenda_range_month">Próximos 30 días</string>
|
||||||
|
<string name="agenda_range_custom_hint">Días</string>
|
||||||
|
<string name="agenda_range_override_hint">Solo por ahora — reinicia tu rango guardado cuando reabras calendula.</string>
|
||||||
|
<string name="agenda_range_showing_label">Mostrando todos los próximos eventos por</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d dia</item>
|
||||||
|
<item quantity="many">%d dias</item>
|
||||||
|
<item quantity="other">%d dias</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_section_event_form">Formulario de nuevo evento</string>
|
||||||
|
<string name="settings_form_fields_hint">Campos mostrados por defecto — todo lo demás se encuentra bajo \"mas campos\"</string>
|
||||||
|
<string name="settings_autofocus_title">Enfocar titulo en nuevos eventos</string>
|
||||||
|
<string name="settings_autofocus_title_hint">Cuando comienzas un nuevo evento, coloca el cursor en el titulo y abre el teclado inmediatamente.</string>
|
||||||
|
<string name="settings_color_unsupported">Permitir colores en calendarios no admitidos</string>
|
||||||
|
<string name="settings_color_unsupported_hint">Algunas calendarios (como algunos CalDAV) no publican un set de colores; un color customizado puede ser eliminado o sobreescrito por su servicio de sincronización. Eso es una limitacion de estos calendarios, no algo que calendula pueda solucionar.</string>
|
||||||
|
<string name="settings_section_notifications">Notificaciones</string>
|
||||||
|
<string name="settings_reminders">Recordatorios de eventos</string>
|
||||||
|
<string name="settings_reminders_hint">Los recordatorios aparecen dos veces? Otra aplicación de calendario tambien los esta generando — desactivarlo en una de las dos apps.</string>
|
||||||
|
<string name="settings_default_reminder">Recordatorio por defecto</string>
|
||||||
|
<string name="settings_default_reminder_allday">Eventos que duran todo el dia</string>
|
||||||
|
<string name="settings_allday_reminder_time">Hora del recordatorio de todo el día</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">Los recordatorios para eventos de todo el día se generaran a las %1$s</string>
|
||||||
|
<string name="reminder_none">Ninguno</string>
|
||||||
|
<string name="reminder_use_default">Usar recordatorio por defecto</string>
|
||||||
|
<string name="reminder_custom_amount">Cantidad</string>
|
||||||
|
<string name="reminder_custom_set">Guardar</string>
|
||||||
|
<string name="settings_calendar_reminders_title">Recordatorios por calendario</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">Sobreescribe el por recordatorio defecto por calendario — De forma separada para eventos con duración fija o que duran todo el dia. Un calendario puede mantener el por defecto o tener su propia configuracion.</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">Por defecto (%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">Notificaciones confiables</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Android puede atrasar los recordatorios para guardar batería. Excluye calendula de la optimización de batería para que se puedan generar a tiempo.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">Excluido de la optimización de batería — los recordatorios llegaran a tiempo.</string>
|
||||||
|
<string name="settings_snooze_duration">Duración al posponer</string>
|
||||||
|
<string name="settings_manage_calendars">Administrar calendarios</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Crear calendarios locales; administrar calendarios sincronizados</string>
|
||||||
|
<string name="settings_section_language">Idioma</string>
|
||||||
|
<string name="settings_language">Idioma de la aplicación</string>
|
||||||
|
<string name="settings_language_auto">Igual que el sistema</string>
|
||||||
|
<string name="settings_translate">Ayuda a traducir</string>
|
||||||
|
<string name="settings_translate_hint">Añade o mejora un idioma en Weblate</string>
|
||||||
|
<string name="settings_appearance_subtitle">Tema, vista por defecto, inicio de semana</string>
|
||||||
|
<string name="settings_event_form_subtitle">Campos por defecto en nuevos eventos</string>
|
||||||
|
<string name="settings_notifications_subtitle">Recordatorios de eventos</string>
|
||||||
|
<string name="settings_license">Licencia</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_about_author">Por Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">Fuente</string>
|
||||||
|
<string name="settings_about_support">Ayuda al desarrollo</string>
|
||||||
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Icono de la aplicación calendula</string>
|
||||||
|
<string name="settings_report_problem">Reportar un problema</string>
|
||||||
|
<string name="settings_report_problem_hint">Enviar un reporte de error o abre el sistema de seguimiento de errores</string>
|
||||||
|
<string name="calendars_local_header">Tus calendarios</string>
|
||||||
|
<string name="calendars_local_empty">Sin calendarios locales todavia. Crea uno para mantener eventos solo en este dispositivo.</string>
|
||||||
|
<string name="calendars_add">Añadir calendario</string>
|
||||||
|
<string name="calendars_disable_hint">Desactiva un calendario para removerlo de la aplicación — sus eventos, filtros y selectores. Nada se eliminara, y puedes reactivarlo en cualquier momento.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Mostrar \"%1$s\" en la aplicación</string>
|
||||||
|
<string name="calendars_synced_header">Calendarios sincronizados</string>
|
||||||
|
<string name="calendars_synced_hint">Estos provienen de cuentas en tu dispositivo. Crea o editalos en su propia aplicación.</string>
|
||||||
|
<string name="calendars_manage_in_app">Gestionar en aplicación</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Mas opciones para %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Habilitar todos</string>
|
||||||
|
<string name="calendars_disable_all">Desactivar todos</string>
|
||||||
|
<string name="calendars_add_account">Añadir cuenta</string>
|
||||||
|
<string name="calendars_new_title">Nuevo calendario</string>
|
||||||
|
<string name="calendars_edit_title">Editar calendario</string>
|
||||||
|
<string name="calendars_name_label">Nombre</string>
|
||||||
|
<string name="calendars_description_hint">Añadir descripción</string>
|
||||||
|
<string name="calendars_delete_confirm_title">¿Eliminar calendario?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" y todos sus eventos serán eliminados permanentemente de este dispositivo.</string>
|
||||||
|
<string name="calendars_write_error">No se pudieron guardar los cambios.</string>
|
||||||
|
<string name="calendars_backup_hint">Los calendarios locales no se sincronizan, exportalos como un archivo .ics para mantener una copia.</string>
|
||||||
|
<string name="calendars_backup_action">Exportar como archivo .ics</string>
|
||||||
|
<string name="calendars_auto_backup">Respaldo automático</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Exporta tus calendarios a una carpeta como un archivo .ics de forma periodica.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Carpeta de respaldo</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Toca para seleccionar una carpeta</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Intervalo</string>
|
||||||
|
<string name="calendars_auto_backup_every">Cada %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Mínimo 30 minutos.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">Sin respaldos automáticos todavía</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Ultimo respaldo: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Ultimo respaldo fallido: %1$s</string>
|
||||||
|
<string name="backup_channel_description">Advierte si los respaldos automáticos fallan repetidamente.</string>
|
||||||
|
<string name="backup_failed_title">El respaldo automático fallo</string>
|
||||||
|
<string name="backup_failed_text">Calendula no pudo generar el archivo de respaldo, comprueba la carpeta de respaldo en ajustes.</string>
|
||||||
|
<string name="calendars_backup_failed">No se pudo exportar el respaldo.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="one">Se exporto %d evento.</item>
|
||||||
|
<item quantity="many">Se exportaron %d eventos.</item>
|
||||||
|
<item quantity="other">Se exportaron %d eventos.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">Importar eventos</string>
|
||||||
|
<string name="import_target_header">Añadir a calendario</string>
|
||||||
|
<string name="import_empty">No se encontraron eventos en este archivo.</string>
|
||||||
|
<string name="import_failed">No se pudo leer el archivo.</string>
|
||||||
|
<string name="import_no_calendar">No hay ningún calendario editable en el cual importar los eventos. Crea un calendario local primero.</string>
|
||||||
|
<string name="import_done_title">Importación completa</string>
|
||||||
|
<string name="import_warning_recurrence">Se omitieron algunas ocurrencias editadas de eventos recurrentes.</string>
|
||||||
|
<string name="import_warning_no_start">Se omitió un evento sin hora de inicio.</string>
|
||||||
|
<string name="import_warning_attendees">No se importaron las listas de invitados.</string>
|
||||||
|
<string name="import_warning_timezone">Se reemplazo una zona horaria desconocida con la de tu dispositivo.</string>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="one">%d evento en este archivo.</item>
|
||||||
|
<item quantity="many">%d eventos en este archivo.</item>
|
||||||
|
<item quantity="other">%d eventos en este archivo.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="one">Importar %d evento</item>
|
||||||
|
<item quantity="many">Importar %d eventos</item>
|
||||||
|
<item quantity="other">Importar %d eventos</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="one">%d evento importado.</item>
|
||||||
|
<item quantity="many">%d eventos importados.</item>
|
||||||
|
<item quantity="other">%d eventos importados.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="one">Se omitió %d archivo ya presente en este calendario.</item>
|
||||||
|
<item quantity="many">Se omitieron %d archivos ya presentes en este calendario.</item>
|
||||||
|
<item quantity="other">Se omitieron %d archivos ya presentes en este calendario.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="shortcut_new_event_long">Crear un nuevo evento</string>
|
||||||
|
<string name="settings_qs_tile">Añadir mosaico de ajustes rápidos</string>
|
||||||
|
<string name="settings_qs_tile_hint">Añadir un recuadro de \"nuevo evento\" al panel de ajustes rápidos.</string>
|
||||||
|
<string name="crash_dialog_title">Calendula ha fallado</string>
|
||||||
|
<string name="crash_dialog_message">Calendula se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string>
|
||||||
|
<string name="crash_dialog_report">Reportar</string>
|
||||||
|
<string name="crash_dialog_dismiss">No por ahora</string>
|
||||||
|
<string name="crash_report_issue_title">Reporte de error</string>
|
||||||
|
<string name="crash_report_clip_label">Reporte de error de Calendula</string>
|
||||||
|
<string name="crash_report_copied">Reporte copiado a tu porta papeles</string>
|
||||||
|
<string name="crash_report_open_failed">No se pudo abrir el sistema de seguimiento de errores. El reporte es en tu portapapeles.</string>
|
||||||
|
<string name="crash_report_body_template">Gracias por reportar un error en Calendula. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%1$s\n</string>
|
||||||
|
<string name="crash_report_body_paste">_(el reporte es demasiado largo para este link — pegalo aquí desde su portapeles.)_</string>
|
||||||
|
<string name="event_attendee_resource">Recurso</string>
|
||||||
|
<string name="settings_section_about">Acerca de</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d veces</string>
|
||||||
|
<string name="recurrence_with_until">%1$s hasta %2$s</string>
|
||||||
|
<string name="recurrence_on_days">%1$s los %2$s</string>
|
||||||
|
<string name="event_edit_managed_hint">Administrado por “%1$s” — el titulo, fecha y recurrencia se mantienen sincronizados con la inflación de tus contactos. Puedes editar los recordatorios, ubicacion y notas.</string>
|
||||||
|
<string name="settings_font_headings">Fuente de los títulos</string>
|
||||||
|
<string name="settings_font_body">Fuente del cuerpo del texto</string>
|
||||||
|
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||||
|
<string name="font_lora">Lora</string>
|
||||||
|
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||||
|
<string name="settings_font_choose_file">Escoger desde archivo…</string>
|
||||||
|
<string name="settings_font_custom_selected">Fuente personalizada</string>
|
||||||
|
<string name="settings_font_import_failed">No se pude leer el archivo como fuente</string>
|
||||||
|
<string name="settings_section_views">Vistas</string>
|
||||||
|
<string name="settings_quick_switch_header">Botón de cambio rápido</string>
|
||||||
|
<string name="settings_quick_switch_hint">Escoge que vistas muestra el botón superior derecho y arrastralas para reordenalas. Las vistas deshabitadas permanecen accesibles desde el menú de navegación.</string>
|
||||||
|
<string name="settings_drawer_order_header">Menú de navegación</string>
|
||||||
|
<string name="settings_drawer_order_hint">Arrastra para cambiar el orden de las vistas que aparecen en el menú de navegación.</string>
|
||||||
|
<string name="reorder_drag_handle">Arrastra para reordenar</string>
|
||||||
|
<string name="settings_views_subtitle">Orden del botón de cambio rápido y menú</string>
|
||||||
|
<string name="settings_special_dates_subtitle">Aniversarios y cumpleaños de los contactos</string>
|
||||||
|
<string name="settings_section_special_dates">Fechas especiales de contactos</string>
|
||||||
|
<string name="settings_special_dates_enable">Mostrar fechas de contactos</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">Refleja los cumpleaños y otras fechas de tus contactos en calendarios locales. Solo puede leer los contactos de este dispositivo — nada es compartido ni editado.</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">Cumpleaños</string>
|
||||||
|
<string name="settings_special_dates_type_anniversary">Aniversarios</string>
|
||||||
|
<string name="settings_special_dates_type_custom">Otras fechas</string>
|
||||||
|
<string name="settings_special_dates_template">Formato de titulo</string>
|
||||||
|
<string name="settings_special_dates_template_hint">Usa {name} para el contacto y {year} para el año (el año de nacimiento u origen del aniversario; oculto cuando es desconocido).</string>
|
||||||
|
<string name="settings_special_dates_reminders">Recordatorios</string>
|
||||||
|
<string name="settings_special_dates_show_year">Mostrar año</string>
|
||||||
|
<string name="settings_special_dates_show_year_hint">Incluye {year} en títulos cuando se conoce el año</string>
|
||||||
|
<string name="settings_special_dates_sync_now">Sincronizar ahora</string>
|
||||||
|
<string name="settings_special_dates_never_synced">No se ha sincronizado todavía</string>
|
||||||
|
<string name="settings_special_dates_last_synced">Sincronizado por ultima vez %1$s</string>
|
||||||
|
<string name="settings_special_dates_calendar_hint">Cambia el color y visibilidad de cada calendario en ajustes de calendarios.</string>
|
||||||
|
<string name="settings_calendar_reminders_managed_hint">Establecido en Fechas especiales de contactos</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">Calendula ya no puede leer tus contactos, por lo que estos calendarios ya no se estan actualizando.</string>
|
||||||
|
<string name="settings_special_dates_disable_title">¿Desactivar las fechas de contactos?</string>
|
||||||
|
<string name="settings_special_dates_disable_all_message">Esto eliminara los calendarios de fechas de contactos y sus eventos. Se perderán todos los recordatorios o notas agregadas.</string>
|
||||||
|
<string name="settings_special_dates_paused_title">Pausado</string>
|
||||||
|
<string name="settings_special_dates_disable_type_message">Esto eliminara el calendario “%1$s” y sus eventos. Se perderán todos los recordatorios o notas agregadas.</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">Desactivar</string>
|
||||||
|
<string name="special_dates_calendar_birthday">Cumpleaños</string>
|
||||||
|
<string name="special_dates_calendar_anniversary">Aniversarios</string>
|
||||||
|
<string name="special_dates_calendar_custom">Fechas especiales</string>
|
||||||
|
<string name="special_dates_default_title_birthday">Cumpleaños de {name} ({year})</string>
|
||||||
|
<string name="special_dates_default_title_anniversary">Aniversario de {name} ({year})</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
|
<string name="settings_special_dates_grant">Permitir acceso</string>
|
||||||
|
<string name="dialog_save">Guardar</string>
|
||||||
|
<string name="settings_font_system">Igual que el sistema</string>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values-fr/strings.xml
Normal file
9
app/src/main/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user