Compare commits
157 Commits
101082064c
...
v2.13.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 225f4c3491 | |||
| 11578a5588 | |||
| 5d887524b1 | |||
| 405ff16233 | |||
| 048f407ba1 | |||
| 69460bc35a | |||
|
|
bf10deaf5c | ||
|
|
b9e861af62 | ||
|
|
2eadda937b | ||
|
|
71b90fa184 | ||
|
|
4e9cf88bd4 | ||
|
|
b999202629 | ||
|
|
6f0e13a4a2 | ||
|
|
9ef7b33800 | ||
|
|
d04b431f2c | ||
|
|
dbeeadff80 | ||
|
|
f4a169ef5f | ||
|
|
604099b3f2 | ||
|
|
a3e6541512 | ||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e | |||
| 2ae4c818ba | |||
| 25ff7ce7ca | |||
| fb2a40114a | |||
| e1f79df67a | |||
| e258a4a7f2 | |||
| 3f27a47d1e | |||
| feb87a1e73 | |||
| 28e464a616 | |||
| 93df6d6d62 | |||
| 49ddb9437b | |||
| e1cf00999d | |||
| 2d0d707a7d | |||
| de1fe31223 | |||
| 273cfce969 | |||
| b6a45b7264 | |||
| c7ef52e426 | |||
| b1151b6516 | |||
| 1664625bc5 | |||
| e8b87446f7 | |||
| dc478516cd | |||
| 8945b877b2 | |||
| 1424f0ffc0 | |||
| 548e581554 | |||
| a6ae4d7e1d | |||
| 614f4f2d75 | |||
| e6736b049a | |||
| 301904be6d | |||
| 3ca7200624 | |||
| c30a153552 | |||
| bb92d02000 | |||
| cd703e9d54 | |||
| f27b0e269d | |||
| 0f31981d26 | |||
| 6471c48528 | |||
| f8a2569831 | |||
| 6ba7f38887 | |||
| 366059b012 | |||
| 3cf1439850 | |||
| b2bfdc0f42 | |||
| 7c9830deda | |||
| 5e98059388 | |||
| 53c0482760 | |||
| f8efa4d3e4 | |||
| 93cc264fbf | |||
| 585cdac4ab | |||
| 9994e8c534 | |||
| 766c2ffcf8 | |||
| add88fbadf | |||
| 09a1aecb76 | |||
| c91608e48a | |||
| 47e8036000 |
@@ -5,6 +5,7 @@ title: "Crash: "
|
||||
labels:
|
||||
- bug
|
||||
- crash
|
||||
- priority:high
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Feature request
|
||||
about: Suggest an idea or improvement
|
||||
title: ""
|
||||
labels:
|
||||
- enhancement
|
||||
- feat
|
||||
---
|
||||
|
||||
### What would you like Calendula to do?
|
||||
|
||||
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask how something works or get help using Calendula
|
||||
title: ""
|
||||
labels:
|
||||
- question
|
||||
---
|
||||
|
||||
### Your question
|
||||
|
||||
|
||||
### What you've tried
|
||||
<!-- so far, if anything -->
|
||||
|
||||
|
||||
### Context
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -55,3 +55,6 @@ Thumbs.db
|
||||
|
||||
# KSP
|
||||
.ksp/
|
||||
|
||||
# Claude Code
|
||||
/CLAUDE.md
|
||||
|
||||
211
CHANGELOG.md
211
CHANGELOG.md
@@ -5,6 +5,195 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [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
|
||||
reminders — and the global defaults under Settings → Notifications — can now
|
||||
hold several lead times instead of just one, so new events can start with, say,
|
||||
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||
multi-select; per-calendar overrides can still inherit the global default or
|
||||
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||
- 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
|
||||
- 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
|
||||
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||
popping up — matching how a disabled calendar's events already stay hidden
|
||||
everywhere else in the app ([#17]).
|
||||
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||
this event** and saving a change to one event in a repeating series silently
|
||||
did nothing — the change was rejected and the edit form simply reappeared with
|
||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||
lands on just that one event and leaves the rest of the series untouched
|
||||
([#16]).
|
||||
- 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
|
||||
|
||||
### Added
|
||||
- See past events your way. Two new settings change how events that have already
|
||||
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||
(Settings → Appearance) separately fades finished events in the month and week
|
||||
views. Both are off by default, an event only counts as finished once it has
|
||||
actually ended (events still in progress are never dimmed), and the lists
|
||||
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||
([#12]).
|
||||
|
||||
### Changed
|
||||
- Readable titles on overlapping events. In the week and day views, events that
|
||||
overlap split a day into slim columns where the title used to be clipped to a
|
||||
character or two. The title now wraps across as many lines as the block can
|
||||
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||
for the suggestion ([#13]).
|
||||
|
||||
## [2.11.2] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||
the cursor in the title field and opens the keyboard right away, so you can type
|
||||
the name without an extra tap. It's on by default and only affects creating an
|
||||
event — editing never grabs focus — and a new **Focus title on new event**
|
||||
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||
suggestion ([#10]).
|
||||
|
||||
## [2.11.1] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- Calendula can now be set as your default calendar app. It registers the
|
||||
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||
that day. Android has no way for an app to make itself the default, so you pick
|
||||
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||
|
||||
## [2.11.0] — 2026-06-27
|
||||
|
||||
### Added
|
||||
- Start the week on any day. The **Week starts on** setting (Settings →
|
||||
Appearance) now offers every weekday — not just Monday or Sunday — alongside
|
||||
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
|
||||
([#3]).
|
||||
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
|
||||
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
|
||||
follow the system setting automatically. It applies everywhere times appear —
|
||||
the week and day timelines, agenda, event details, search and reminders — so
|
||||
the format is now consistent across the whole app. Thanks to @zmaherdev for
|
||||
the suggestion ([#6]).
|
||||
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
|
||||
Appearance) draws a faint separator at each hour in the week and day views,
|
||||
making it easier to see when events start and end. Off by default. Thanks to
|
||||
@zmaherdev for the suggestion ([#5]).
|
||||
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
|
||||
range** and **Agenda widget range** settings (Settings → Appearance) let the
|
||||
agenda screen and its home-screen widget each show just today, the rest of this
|
||||
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
|
||||
days — "This week" follows your week-start. The agenda also gains a bar at the
|
||||
top naming the exact dates in view, with a button to switch the range just for
|
||||
the current session (it resets when you reopen the app); the bar can be turned
|
||||
off. Thanks to @zmaherdev for the suggestion ([#4]).
|
||||
- Automatic backup of local calendars. A new **Automatic backup** option
|
||||
(Settings → Calendars → Backup) periodically exports your local calendars to an
|
||||
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
|
||||
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
|
||||
is a one-way export and never touches your synced accounts. Thanks to @shield
|
||||
for the discussion that inspired it ([#7], [#8]).
|
||||
- Field icons in the event-form settings. Each optional-field toggle (Settings →
|
||||
Event form) now shows the same icon the field uses in the new-event form, so
|
||||
the list is easier to scan.
|
||||
- Help translate Calendula. A **Help translate** link at the top of **Settings →
|
||||
App language** opens the project's Weblate, where you can add or improve a
|
||||
language in your browser — no coding needed. Contributions in any language are
|
||||
welcome.
|
||||
|
||||
### Changed
|
||||
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
||||
agenda), and the agenda's empty state now reads "You're all caught up".
|
||||
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
|
||||
into collapsible, source-branded cards — each account shows its app's icon —
|
||||
with a per-account menu to enable or disable all of its calendars at once or
|
||||
open the account in its source app.
|
||||
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
|
||||
moved up with the other global reminder options, and the per-calendar reminder
|
||||
overrides now fold into a single expandable section.
|
||||
- Moved **Add Quick Settings tile** out of the event-form section to its own
|
||||
top-level entry in Settings.
|
||||
|
||||
## [2.10.0] — 2026-06-25
|
||||
|
||||
### Added
|
||||
@@ -627,3 +816,25 @@ automatically, with zero telemetry and no internet permission.
|
||||
|
||||
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
|
||||
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
|
||||
[#3]: https://codeberg.org/jlmakiola/calendula/issues/3
|
||||
[#4]: https://codeberg.org/jlmakiola/calendula/issues/4
|
||||
[#5]: https://codeberg.org/jlmakiola/calendula/issues/5
|
||||
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
||||
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
|
||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||
[#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
|
||||
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
|
||||
14
README.md
14
README.md
@@ -68,7 +68,8 @@ database, no sync stack reinvented.
|
||||
|
||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||
expressive motion and shapes, light/dark theme
|
||||
- German and English UI, per-app language setting
|
||||
- German and English UI, per-app language setting — and [open to community
|
||||
translations](#-translations)
|
||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||
never leaves the device
|
||||
|
||||
@@ -108,6 +109,17 @@ without reinstalling. Or build from source — see below.
|
||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
||||
|
||||
## 🌍 Translations
|
||||
|
||||
Calendula ships in German and English, and you're warmly invited to add your
|
||||
language. Translations are managed on a self-hosted **Weblate**:
|
||||
|
||||
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||
|
||||
No coding needed — register on the Weblate server, pick (or request) a language,
|
||||
and translate the strings in your browser. You can also reach this link in the
|
||||
app from the top of **Settings → App language**.
|
||||
|
||||
## 📜 License
|
||||
|
||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21000
|
||||
versionName = "2.10.0"
|
||||
versionCode = 21301
|
||||
versionName = "2.13.1"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -90,6 +90,8 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||
@@ -152,6 +154,9 @@ dependencies {
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.androidx.documentfile)
|
||||
|
||||
implementation(libs.androidx.glance.appwidget)
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
|
||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -16,6 +16,18 @@
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-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
|
||||
# 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
|
||||
|
||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
|
||||
debug build paints the adaptive-icon background burnt orange instead, so the
|
||||
debug icon reads at a glance as "not the real app" on the home screen. The
|
||||
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
|
||||
-->
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFB23B00</color>
|
||||
</resources>
|
||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Debug-build resource overrides. Merged on top of src/main for the `debug`
|
||||
build type only (release/releaseTest keep the production values), so the
|
||||
debug app is unmistakable on the launcher: its own label, alongside the
|
||||
real app thanks to the `.debug` applicationId suffix.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Calendula Debug</string>
|
||||
</resources>
|
||||
@@ -5,6 +5,15 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
|
||||
<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
|
||||
exempt Calendula from battery optimisation (so reminder broadcasts aren't
|
||||
@@ -59,6 +68,35 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Be selectable as the system calendar app. Android has no API for
|
||||
an app to make itself the default, so registering the filters
|
||||
launchers and the OS use is what lets the user pick Calendula
|
||||
from the system chooser when a date action fires (issue #9).
|
||||
APP_CALENDAR is the "open the calendar app" action; the VIEW
|
||||
filters below catch a tapped date. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.APP_CALENDAR" />
|
||||
</intent-filter>
|
||||
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
|
||||
time Uri (content://com.android.calendar/time/<epochMillis>);
|
||||
some surfaces use the time/epoch mime type. We open the day view
|
||||
on that date (MainActivity.calendarTimeDateOrNull). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:scheme="content"
|
||||
android:host="com.android.calendar"
|
||||
android:pathPrefix="/time" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="time/epoch" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -74,6 +112,17 @@
|
||||
<data android:mimeType="text/calendar" />
|
||||
</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"). -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Application entry point. Registered as android:name=".CalendulaApp"
|
||||
@@ -16,5 +26,42 @@ class CalendulaApp : Application() {
|
||||
// Install first thing so startup crashes are captured too (privacy-
|
||||
// respecting, on-device; the user submits the report by hand).
|
||||
CrashReporter.install(this)
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the scheduled auto-backup work back in line with the saved settings
|
||||
* on every launch — re-arms it after a reinstall and, crucially, cancels any
|
||||
* orphaned work once backup has been turned off.
|
||||
*/
|
||||
private fun reconcileAutoBackup() {
|
||||
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
val prefs = deps.settingsPrefs()
|
||||
BackupScheduler.apply(
|
||||
context = this@CalendulaApp,
|
||||
enabled = prefs.autoBackupEnabled.first(),
|
||||
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = prefs.autoBackupFolderUri.first() != null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,15 +4,20 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -20,15 +25,28 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
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.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@@ -46,6 +64,11 @@ class MainActivity : AppCompatActivity() {
|
||||
// by CalendarHost's import flow.
|
||||
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
|
||||
// over the calendar on the next launch (the single-crash path). A startup
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
@@ -70,6 +93,7 @@ class MainActivity : AppCompatActivity() {
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsertForm = intent.insertFormOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
// One activity-scoped SettingsViewModel drives both the theme here
|
||||
@@ -81,19 +105,51 @@ class MainActivity : AppCompatActivity() {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
// The app-wide clock convention: the time-format preference resolved
|
||||
// against the device's 24-hour system setting, provided once here so
|
||||
// every time label reads it via LocalUse24HourFormat.
|
||||
val context = LocalContext.current
|
||||
val use24Hour = remember(settings.timeFormat, context) {
|
||||
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
}
|
||||
// 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(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
typography = typography,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
) {
|
||||
RootScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
requestedDetailKey = requestedDetailKey,
|
||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||
widgetNavRequest = requestedNav,
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = { requestedInsertForm = null },
|
||||
)
|
||||
}
|
||||
// A persistent corner marker so a debug build is never
|
||||
// mistaken for the production app; compiled out of release.
|
||||
if (BuildConfig.DEBUG) DebugRibbon()
|
||||
}
|
||||
pendingCrashReport?.let { report ->
|
||||
CrashReportDialog(
|
||||
report = report,
|
||||
@@ -125,6 +181,7 @@ class MainActivity : AppCompatActivity() {
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,10 +195,64 @@ class MainActivity : AppCompatActivity() {
|
||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||
else -> null
|
||||
} ?: return null
|
||||
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
|
||||
if (uri.host == CALENDAR_PROVIDER_HOST) return null
|
||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||
* <epochMillis>`. Null for any other intent. The matching manifest filter is
|
||||
* what lets users pick Calendula from the system calendar chooser (issue #9).
|
||||
*/
|
||||
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||
val segments = uri.pathSegments
|
||||
if (segments.firstOrNull() != "time") return null
|
||||
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||
return Instant.fromEpochMilliseconds(millis)
|
||||
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
}
|
||||
|
||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||
// the day view rooted over the default home view (OpenDate source = null).
|
||||
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||
// 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 eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||
return when {
|
||||
@@ -183,12 +294,20 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
// The calendar provider's authority/host. A date tap arrives as
|
||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||
|
||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||
|
||||
// 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
|
||||
// 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"
|
||||
@@ -252,5 +371,19 @@ class MainActivity : AppCompatActivity() {
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package de.jeanlucmakiola.calendula.data.backup
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Clock
|
||||
|
||||
/**
|
||||
* Schedules and runs the automatic periodic `.ics` backup of local calendars
|
||||
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
|
||||
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
|
||||
* everything is local provider reads + a local file write.
|
||||
*/
|
||||
object BackupScheduler {
|
||||
|
||||
private const val WORK_NAME = "auto-backup"
|
||||
private const val WORK_NAME_NOW = "auto-backup-now"
|
||||
|
||||
/**
|
||||
* Reconcile the scheduled work with the current settings: enqueue a unique
|
||||
* periodic request when backup is on and a folder is set, otherwise cancel
|
||||
* it. WorkManager persists the request across reboots on its own.
|
||||
*
|
||||
* The first run is delayed by one interval so it never overlaps an immediate
|
||||
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
|
||||
*/
|
||||
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
if (!enabled || !hasFolder) {
|
||||
workManager.cancelUniqueWork(WORK_NAME)
|
||||
// Also cancel any pending immediate run — otherwise a failing run-now
|
||||
// keeps retrying after the user has turned backup off.
|
||||
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||
return
|
||||
}
|
||||
// WorkManager's own floor is 15 min; the UI floors the user choice at 30.
|
||||
val interval = intervalMinutes.coerceAtLeast(15L)
|
||||
val request = PeriodicWorkRequestBuilder<BackupWorker>(interval, TimeUnit.MINUTES)
|
||||
.setInitialDelay(interval, TimeUnit.MINUTES)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one export immediately (e.g. right after enabling or changing the
|
||||
* folder) for instant feedback. Unique + REPLACE so rapid taps coalesce into
|
||||
* a single run rather than racing each other.
|
||||
*/
|
||||
fun runNow(context: Context) {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_NAME_NOW,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
OneTimeWorkRequestBuilder<BackupWorker>().build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
|
||||
* overwriting the previous file. Pulls its collaborators through a Hilt
|
||||
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
|
||||
* no custom factory / Application wiring needed. Records the outcome for the
|
||||
* settings status line, and notifies after repeated failures.
|
||||
*/
|
||||
class BackupWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Deps {
|
||||
fun settingsPrefs(): SettingsPrefs
|
||||
fun repository(): CalendarRepository
|
||||
fun exporter(): IcsExporter
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
val prefs = deps.settingsPrefs()
|
||||
// Respect the toggle even for already-queued work: if backup was turned
|
||||
// off, no-op (and don't retry) so a lingering run can't revive itself.
|
||||
if (!prefs.autoBackupEnabled.first()) return Result.success()
|
||||
val folder = prefs.autoBackupFolderUri.first()
|
||||
?: return Result.failure() // nothing to write to — leave scheduling to settings
|
||||
val now = System.currentTimeMillis()
|
||||
return try {
|
||||
val events = deps.repository().exportEvents()
|
||||
val content = IcsWriter().writeCalendar(events, Clock.System.now())
|
||||
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
|
||||
prefs.recordAutoBackupRun(success = true, atMillis = now)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
prefs.recordAutoBackupRun(success = false, atMillis = now)
|
||||
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
|
||||
notifyFailure(applicationContext)
|
||||
}
|
||||
Log.w(TAG, "Automatic backup failed", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyFailure(context: Context) {
|
||||
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (!canPost) return
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.backup_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = context.getString(R.string.backup_channel_description) },
|
||||
)
|
||||
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
val tap = launch?.let {
|
||||
PendingIntent.getActivity(
|
||||
context, 0, it,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(context.getString(R.string.backup_failed_title))
|
||||
.setContentText(context.getString(R.string.backup_failed_text))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.apply { tap?.let(::setContentIntent) }
|
||||
.build()
|
||||
try {
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Could not post backup-failure notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val BACKUP_FILE_NAME = "calendula-backup.ics"
|
||||
private const val FAILURE_NOTIFY_THRESHOLD = 2
|
||||
private const val CHANNEL_ID = "backup"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val TAG = "BackupWorker"
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes(
|
||||
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
|
||||
* [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)
|
||||
}
|
||||
@@ -24,10 +24,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
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.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
@@ -97,6 +101,55 @@ interface CalendarDataSource {
|
||||
/** Permanently delete a local calendar the app owns, with all its events. */
|
||||
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]
|
||||
* (minutes from local midnight) is the wall-clock time all-day reminders
|
||||
@@ -292,6 +345,140 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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> {
|
||||
ensureObserversRegistered()
|
||||
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
|
||||
@@ -575,35 +762,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
.distinct()
|
||||
|
||||
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
|
||||
// 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
|
||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
// The provider's invariant: recurring rows carry RRULE+DURATION
|
||||
// (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 globally-unique UID so a later .ics backup/restore can identify
|
||||
// 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
|
||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||
val values = buildEventInsertValues(
|
||||
form,
|
||||
uid = "${UUID.randomUUID()}@calendula",
|
||||
times = form.toWriteTimes(ZoneId.systemDefault()),
|
||||
).toContentValues().apply {
|
||||
// A null colour just leaves both columns unset (the event inherits
|
||||
// its calendar's colour), so only the key/raw cases are written.
|
||||
when {
|
||||
@@ -615,19 +782,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
// Best effort (spec §8): the event exists at this point — a reminder
|
||||
// that fails to attach is logged, not surfaced as a failed create.
|
||||
encodedReminders(form, allDayReminderTimeMinutes)
|
||||
.forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||
// 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.
|
||||
@@ -635,6 +790,47 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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 {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A managed all-day event's reminders, encoded so they fire at
|
||||
* [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(
|
||||
eventId: Long,
|
||||
original: EventForm,
|
||||
@@ -1038,6 +1234,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
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 {
|
||||
const val TAG = "CalendarDataSource"
|
||||
|
||||
@@ -1047,6 +1254,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
*/
|
||||
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
|
||||
* occurrence (~2 years). Wide enough for everyday series; a series that
|
||||
|
||||
@@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
} else {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,26 +23,29 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
attendees: List<Attendee>,
|
||||
reminders: List<Reminder>,
|
||||
): EventDetail? {
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
if (begin < 0L) {
|
||||
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
||||
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
||||
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
||||
// Dropping negatives made every occurrence of such a series un-openable
|
||||
// (the detail loads the ancient series-master DTSTART), see issue #34.
|
||||
if (isNull(EventDetailProjection.IDX_DTSTART)) {
|
||||
Log.w(TAG, "Dropping event with missing dtstart")
|
||||
return null
|
||||
}
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
||||
// 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)) {
|
||||
begin
|
||||
} else {
|
||||
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
|
||||
if (rawEnd < begin) {
|
||||
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
|
||||
return null
|
||||
}
|
||||
rawEnd
|
||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||
}
|
||||
|
||||
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||
|
||||
@@ -49,6 +49,42 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
|
||||
"P${(dtEndMillis - dtStartMillis) / 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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dirty-checked column values for updating an existing Events row: only what
|
||||
* the user actually changed is written, so untouched fields can't stomp
|
||||
@@ -118,8 +154,14 @@ internal fun buildEventUpdateValues(
|
||||
* provider clone the series row and apply these on top. Unlike the series
|
||||
* update there is no dirty check — the exception is a fresh row, so every
|
||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||
* clone starts from the parent's values). An exception is a single event:
|
||||
* DTEND, never RRULE/DURATION.
|
||||
* clone starts from the parent's values).
|
||||
*
|
||||
* The occurrence's length travels as DURATION, never DTEND: the provider
|
||||
* rejects DTEND on an exception outright (`CalendarProvider2`:
|
||||
* "Exceptions can't overwrite dtend") and derives the instance end from
|
||||
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||
* "only this event" fail on-device (Codeberg #16).
|
||||
*/
|
||||
internal fun buildOccurrenceExceptionValues(
|
||||
form: EventForm,
|
||||
@@ -131,7 +173,7 @@ internal fun buildOccurrenceExceptionValues(
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
|
||||
@@ -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,
|
||||
// so the mapper only reads it for local calendars).
|
||||
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 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_DISPLAY_NAME = 1
|
||||
@@ -27,6 +35,7 @@ internal object CalendarProjection {
|
||||
const val IDX_VISIBLE = 5
|
||||
const val IDX_ACCESS_LEVEL = 6
|
||||
const val IDX_DESCRIPTION = 7
|
||||
const val IDX_MANAGED_MARKER = 8
|
||||
}
|
||||
|
||||
internal object InstanceProjection {
|
||||
|
||||
@@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
* of DTEND — reconstruct the end the same way the `.ics` export does.
|
||||
*/
|
||||
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)
|
||||
if (dtStart < 0L) return null
|
||||
val end = when {
|
||||
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
|
||||
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
|
||||
|
||||
@@ -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.calendula.data.prefs.CalendarReminderOverride
|
||||
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: CalendarReminderOverride) {
|
||||
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||
val minutes = when (override) {
|
||||
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||
CalendarReminderOverride.None -> emptyList()
|
||||
is CalendarReminderOverride.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,
|
||||
CalendarReminderOverride.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)
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
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.ReminderAlertStore
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -45,6 +49,18 @@ abstract class DataBindModule {
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindContactSpecialDatesDataSource(
|
||||
impl: AndroidContactSpecialDatesDataSource,
|
||||
): ContactSpecialDatesDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSpecialDatesCalendarSpec(
|
||||
impl: AndroidSpecialDatesCalendarSpec,
|
||||
): SpecialDatesCalendarSpec
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
@@ -28,6 +29,36 @@ class IcsExporter @Inject constructor(
|
||||
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||
}
|
||||
|
||||
/**
|
||||
* Write [content] to [fileName] inside the persisted SAF tree [folder],
|
||||
* overwriting it if it already exists (the automatic-backup destination).
|
||||
* Requires a persisted write grant on [folder]. Throws on failure.
|
||||
*/
|
||||
fun writeToFolder(folder: Uri, fileName: String, content: String) {
|
||||
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||
?: throw IOException("Backup folder is not accessible")
|
||||
if (!dir.exists() || !dir.canWrite()) {
|
||||
throw IOException("Backup folder is missing or not writable")
|
||||
}
|
||||
// Reuse the canonical file if present and clean up any "name (1).ics"
|
||||
// duplicates SAF may have created if two runs ever raced — so we always
|
||||
// converge on a single overwritten file.
|
||||
val base = fileName.substringBeforeLast('.')
|
||||
val ext = fileName.substringAfterLast('.', "")
|
||||
var target: DocumentFile? = null
|
||||
for (child in dir.listFiles()) {
|
||||
val name = child.name ?: continue
|
||||
when {
|
||||
name == fileName -> target = child
|
||||
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
|
||||
}
|
||||
}
|
||||
val file = target
|
||||
?: dir.createFile(MIME_CALENDAR, fileName)
|
||||
?: throw IOException("Could not create backup file in the chosen folder")
|
||||
writeDocument(file.uri, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage [content] in a private cache file and return a shareable content
|
||||
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||
@@ -42,5 +73,6 @@ class IcsExporter @Inject constructor(
|
||||
|
||||
private companion object {
|
||||
const val SHARE_DIR = "shared_ics"
|
||||
const val MIME_CALENDAR = "text/calendar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,18 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import 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.map
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -19,18 +28,52 @@ import javax.inject.Singleton
|
||||
/** Light/dark override. SYSTEM follows the device setting. */
|
||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||
|
||||
/** Week-start override. AUTO derives the first day from the active locale. */
|
||||
enum class WeekStartPref { AUTO, MONDAY, SUNDAY }
|
||||
/**
|
||||
* Week-start override. [Auto] derives the first day from the active locale;
|
||||
* [Day] pins a specific weekday (any of the seven).
|
||||
*/
|
||||
sealed interface WeekStartPref {
|
||||
data object Auto : WeekStartPref
|
||||
data class Day(val day: DayOfWeek) : WeekStartPref
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the preference to a concrete first-day-of-week. AUTO reads the
|
||||
* locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||
* Clock convention for time-of-day labels. AUTO follows the device's 24-hour
|
||||
* system setting; the others force a 12- or 24-hour clock app-wide.
|
||||
*/
|
||||
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
||||
|
||||
/**
|
||||
* How the Agenda screen treats events that have already finished today: [SHOW]
|
||||
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
|
||||
* them from the list entirely.
|
||||
*/
|
||||
enum class PastEventDisplay { SHOW, DIM, HIDE }
|
||||
|
||||
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
|
||||
fun parsePastEventDisplay(
|
||||
stored: String?,
|
||||
default: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
|
||||
|
||||
/**
|
||||
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
|
||||
* device's `DateFormat.is24HourFormat` value).
|
||||
*/
|
||||
fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
|
||||
TimeFormatPref.AUTO -> systemIs24Hour
|
||||
TimeFormatPref.TWELVE_HOUR -> false
|
||||
TimeFormatPref.TWENTY_FOUR_HOUR -> true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the preference to a concrete first-day-of-week. [WeekStartPref.Auto]
|
||||
* reads the locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||
*/
|
||||
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
|
||||
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
|
||||
is WeekStartPref.Day -> day
|
||||
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
|
||||
WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +98,7 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
|
||||
prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO)
|
||||
parseWeekStart(prefs[WEEK_START_KEY])
|
||||
}
|
||||
|
||||
suspend fun setThemeMode(mode: ThemeMode) {
|
||||
@@ -66,8 +109,129 @@ class SettingsPrefs @Inject constructor(
|
||||
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) {
|
||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
||||
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
||||
}
|
||||
|
||||
/** Clock convention for time-of-day labels (v2.11). Defaults to AUTO (system). */
|
||||
val timeFormat: Flow<TimeFormatPref> = store.data.map { prefs ->
|
||||
prefs[TIME_FORMAT_KEY].toEnum(TimeFormatPref.AUTO)
|
||||
}
|
||||
|
||||
suspend fun setTimeFormat(pref: TimeFormatPref) {
|
||||
store.edit { it[TIME_FORMAT_KEY] = pref.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline draws a faint separator line at each hour
|
||||
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
|
||||
*/
|
||||
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setShowHourLines(enabled: Boolean) {
|
||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How the Agenda screen treats events that already ended today. Defaults to
|
||||
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
|
||||
* or hiding.
|
||||
*/
|
||||
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
|
||||
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
|
||||
}
|
||||
|
||||
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the month/week grids fade events that have already finished.
|
||||
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
|
||||
*/
|
||||
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setDimCompletedEvents(enabled: Boolean) {
|
||||
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||
* widget's [agendaWidgetRange].
|
||||
*/
|
||||
val agendaScreenRange: Flow<AgendaRange> = store.data.map { prefs ->
|
||||
parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month)
|
||||
}
|
||||
|
||||
suspend fun setAgendaScreenRange(range: AgendaRange) {
|
||||
store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
|
||||
/** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */
|
||||
val agendaWidgetRange: Flow<AgendaRange> = store.data.map { prefs ->
|
||||
parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month)
|
||||
}
|
||||
|
||||
suspend fun setAgendaWidgetRange(range: AgendaRange) {
|
||||
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||
* the session range switcher (v2.11). Default ON.
|
||||
*/
|
||||
val agendaShowRangeBar: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAgendaShowRangeBar(enabled: Boolean) {
|
||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +248,47 @@ class SettingsPrefs @Inject constructor(
|
||||
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
|
||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||
@@ -102,6 +307,20 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening the new-event form focuses the title field and raises the
|
||||
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||
* gets a title, so this saves a tap; users who set the time/calendar first
|
||||
* can turn it off. Only the create form auto-focuses — editing never does.
|
||||
*/
|
||||
val autofocusEventTitle: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAutofocusEventTitle(enabled: Boolean) {
|
||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||
* for users whose only calendar app this is, reminders are essential; the
|
||||
@@ -144,35 +363,36 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reminder lead time (minutes before start) prefilled on new
|
||||
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
||||
* as the factory default so existing users aren't surprised by reminders they
|
||||
* never asked for. Stored as a string so "none" is distinct from a numeric
|
||||
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
||||
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||
* The default reminder lead times (minutes before start) prefilled on new
|
||||
* **timed** events. The empty list = no default reminder — the prior
|
||||
* behaviour, kept as the factory default so existing users aren't surprised by
|
||||
* reminders they never asked for. Stored as a comma-joined list of minutes, or
|
||||
* "none"/empty for no reminder (also the unset state). A legacy single value
|
||||
* ("30") parses transparently to a one-element list. Per-calendar overrides in
|
||||
* [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||
*/
|
||||
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
||||
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_REMINDER_KEY].toReminderList()
|
||||
}
|
||||
|
||||
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||
suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reminder lead time prefilled on new **all-day** events, in
|
||||
* The default reminder lead times prefilled on new **all-day** events, in
|
||||
* minutes before the start of the day. All-day events want day-scale lead
|
||||
* times ("1 day before"), so they have their own default rather than reusing
|
||||
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
||||
* to all-day events — they always use this global value.
|
||||
* the timed one. Empty list = no default. Per-calendar overrides do **not**
|
||||
* apply to all-day events — they always use this global value.
|
||||
*/
|
||||
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
||||
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
|
||||
}
|
||||
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
||||
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,13 +429,13 @@ class SettingsPrefs @Inject constructor(
|
||||
/**
|
||||
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||
* timed default for its new events: a `null` value means "no reminder", an int
|
||||
* means that lead time. A calendar **absent** from the map inherits the global
|
||||
* default. Serialised as `id=value;id=value`, with `none` for an explicit
|
||||
* no-reminder override. (All-day events ignore this and use
|
||||
* [defaultAllDayReminderMinutes].)
|
||||
* timed default for its new events: an **empty** list means "no reminder", a
|
||||
* non-empty list means those lead times. A calendar **absent** from the map
|
||||
* inherits the global default. Serialised as `id=value;id=value`, where a value
|
||||
* is a comma-joined minute list or `none` for an explicit no-reminder override.
|
||||
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||
*/
|
||||
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
@@ -232,7 +452,7 @@ class SettingsPrefs @Inject constructor(
|
||||
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||
* inherit the global all-day default; present null = no reminder).
|
||||
*/
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
@@ -248,6 +468,141 @@ class SettingsPrefs @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
/** Whether periodic automatic `.ics` export of local calendars is on. Default OFF. */
|
||||
val autoBackupEnabled: Flow<Boolean> = store.data.map { it[AUTO_BACKUP_ENABLED_KEY] ?: false }
|
||||
|
||||
suspend fun setAutoBackupEnabled(enabled: Boolean) {
|
||||
store.edit { it[AUTO_BACKUP_ENABLED_KEY] = enabled }
|
||||
}
|
||||
|
||||
/** Export interval in minutes. Default daily; floored at [MIN_BACKUP_INTERVAL]. */
|
||||
val autoBackupIntervalMinutes: Flow<Long> = store.data.map { prefs ->
|
||||
(prefs[AUTO_BACKUP_INTERVAL_KEY] ?: DEFAULT_BACKUP_INTERVAL).coerceAtLeast(MIN_BACKUP_INTERVAL)
|
||||
}
|
||||
|
||||
suspend fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||
store.edit { it[AUTO_BACKUP_INTERVAL_KEY] = minutes.coerceAtLeast(MIN_BACKUP_INTERVAL) }
|
||||
}
|
||||
|
||||
/** Persisted SAF tree Uri of the destination folder, or null if unset. */
|
||||
val autoBackupFolderUri: Flow<String?> = store.data.map { it[AUTO_BACKUP_FOLDER_KEY] }
|
||||
|
||||
suspend fun setAutoBackupFolderUri(uri: String?) {
|
||||
store.edit { prefs ->
|
||||
if (uri == null) prefs.remove(AUTO_BACKUP_FOLDER_KEY) else prefs[AUTO_BACKUP_FOLDER_KEY] = uri
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of the last automatic run, for the settings status line. */
|
||||
val autoBackupStatus: Flow<BackupStatus> = store.data.map { prefs ->
|
||||
BackupStatus(
|
||||
lastRun = prefs[AUTO_BACKUP_LAST_RUN_KEY] ?: 0L,
|
||||
lastSuccess = prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] ?: true,
|
||||
consecutiveFailures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
/** Record an automatic run's outcome; resets the failure streak on success. */
|
||||
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
|
||||
store.edit { prefs ->
|
||||
prefs[AUTO_BACKUP_LAST_RUN_KEY] = atMillis
|
||||
prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] = success
|
||||
val failures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0
|
||||
prefs[AUTO_BACKUP_FAILURES_KEY] = if (success) 0 else failures + 1
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
null -> DEFAULT_FORM_FIELDS
|
||||
else -> stored.split(',')
|
||||
@@ -255,12 +610,60 @@ class SettingsPrefs @Inject constructor(
|
||||
.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 {
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
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 AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val 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 AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
@@ -282,76 +685,169 @@ class SettingsPrefs @Inject constructor(
|
||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||
internal val DEFAULT_FORM_FIELDS =
|
||||
setOf(EventFormField.Location, EventFormField.Description)
|
||||
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||
internal val AUTO_BACKUP_LAST_RUN_KEY = longPreferencesKey("auto_backup_last_run")
|
||||
internal val AUTO_BACKUP_LAST_SUCCESS_KEY = booleanPreferencesKey("auto_backup_last_success")
|
||||
internal val AUTO_BACKUP_FAILURES_KEY = intPreferencesKey("auto_backup_failures")
|
||||
/** Default automatic-backup interval: daily. */
|
||||
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||
const val MIN_BACKUP_INTERVAL = 30L
|
||||
|
||||
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]). */
|
||||
data class BackupStatus(
|
||||
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||
val lastRun: Long,
|
||||
val lastSuccess: Boolean,
|
||||
val consecutiveFailures: Int,
|
||||
)
|
||||
|
||||
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||
sealed interface CalendarReminderOverride {
|
||||
/** No override — the calendar uses the global default. */
|
||||
data object Inherit : CalendarReminderOverride
|
||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||
data object None : CalendarReminderOverride
|
||||
/** A specific lead time in minutes before the event start. */
|
||||
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
||||
/** Specific lead times in minutes before the event start (non-empty). */
|
||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead time to prefill on a new event: the matching per-calendar override
|
||||
* The stored override for [calendarId] in an override map, as a
|
||||
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
|
||||
* empty → [CalendarReminderOverride.None]) — the inverse of how
|
||||
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
|
||||
*/
|
||||
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead times to prefill on a new event: the matching per-calendar override
|
||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
||||
* it can be unit-tested.
|
||||
* events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
|
||||
* Pure so it can be unit-tested.
|
||||
*/
|
||||
fun resolveDefaultReminder(
|
||||
timedGlobal: Int?,
|
||||
allDayGlobal: Int?,
|
||||
timedOverrides: Map<Long, Int?>,
|
||||
allDayOverrides: Map<Long, Int?>,
|
||||
timedGlobal: List<Int>,
|
||||
allDayGlobal: List<Int>,
|
||||
timedOverrides: Map<Long, List<Int>>,
|
||||
allDayOverrides: Map<Long, List<Int>>,
|
||||
calendarId: Long?,
|
||||
isAllDay: Boolean,
|
||||
): Int? {
|
||||
): List<Int> {
|
||||
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||
overrides[calendarId]
|
||||
overrides.getValue(calendarId)
|
||||
} else {
|
||||
global
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the key). */
|
||||
private fun MutableMap<Long, Int?>.applyOverride(
|
||||
/**
|
||||
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
||||
* key; [None] and an empty [Minutes] both store the empty list).
|
||||
*/
|
||||
private fun MutableMap<Long, List<Int>>.applyOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
) {
|
||||
when (override) {
|
||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||
CalendarReminderOverride.None -> put(calendarId, null)
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
||||
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||
private const val WEEK_START_AUTO = "AUTO"
|
||||
|
||||
private fun WeekStartPref.storageValue(): String = when (this) {
|
||||
WeekStartPref.Auto -> WEEK_START_AUTO
|
||||
is WeekStartPref.Day -> day.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the stored week-start value. "AUTO"/null/garbage → [WeekStartPref.Auto];
|
||||
* a [DayOfWeek] name → [WeekStartPref.Day]. The legacy "MONDAY"/"SUNDAY" enum
|
||||
* values migrate transparently, since both are valid day names.
|
||||
*/
|
||||
private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
|
||||
null, WEEK_START_AUTO -> WeekStartPref.Auto
|
||||
else -> DayOfWeek.entries.firstOrNull { it.name == stored }
|
||||
?.let { WeekStartPref.Day(it) } ?: WeekStartPref.Auto
|
||||
}
|
||||
|
||||
private const val NONE = "none"
|
||||
private const val ENTRY_SEP = ";"
|
||||
private const val KEY_VALUE_SEP = "="
|
||||
private const val LIST_SEP = ","
|
||||
|
||||
private fun String?.toReminderMinutes(): Int? = when (this) {
|
||||
null, "", NONE -> null
|
||||
else -> toIntOrNull()
|
||||
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||
|
||||
/**
|
||||
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
|
||||
* one-element list. Non-numeric parts are dropped defensively.
|
||||
*/
|
||||
private fun String?.toReminderList(): List<Int> = when {
|
||||
this == null || isEmpty() || this == NONE -> emptyList()
|
||||
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
|
||||
}
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
||||
/** Serialise lead times for storage: "none" when empty, else comma-joined. */
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||
if (stored.isNullOrBlank()) return emptyMap()
|
||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||
id to value
|
||||
// Only the deliberate "none" sentinel means an explicit no-reminder
|
||||
// override (empty list); a non-sentinel value that parses to no valid
|
||||
// minutes is garbage and drops the entry, so the calendar inherits the
|
||||
// global default rather than silently reading as "no reminder".
|
||||
when (val value = parts[1]) {
|
||||
NONE -> id to emptyList()
|
||||
else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it }
|
||||
?: return@mapNotNull null
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
||||
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
|
||||
import android.provider.CalendarContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,6 +17,29 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
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
|
||||
* except alerts whose calendar the user has disabled in-app. The caller still
|
||||
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
||||
* provider.
|
||||
*/
|
||||
internal fun postableAlerts(
|
||||
due: List<ReminderAlert>,
|
||||
disabledCalendarIds: Set<Long>,
|
||||
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
|
||||
|
||||
/**
|
||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||
* visible notifications (the Etar model — the provider broadcasts
|
||||
@@ -32,6 +56,8 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var alertStore: ReminderAlertStore
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
@@ -46,8 +72,17 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
if (settingsPrefs.remindersEnabled.first()) {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
due.forEach(notifier::post)
|
||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||
val postable = postableAlerts(due, disabled)
|
||||
// Suppress reminders for disabled calendars, but still mark
|
||||
// every due alert fired so the provider stops re-broadcasting
|
||||
// the suppressed ones. Stash those suppressed alerts so
|
||||
// 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)
|
||||
suppressedStore.stash(due - postable.toSet(), now)
|
||||
suppressedStore.purgeExpired(now)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
|
||||
@@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
|
||||
private const val EXTRA_ALERT_ID = "alert_id"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
private const val EXTRA_BEGIN = "begin"
|
||||
private const val EXTRA_END = "end"
|
||||
private const val EXTRA_TITLE = "title"
|
||||
@@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
this.action = action
|
||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
putExtra(EXTRA_END, alert.endMillis)
|
||||
putExtra(EXTRA_TITLE, alert.title)
|
||||
@@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
return ReminderAlert(
|
||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import javax.inject.Singleton
|
||||
data class ReminderAlert(
|
||||
val alertId: Long,
|
||||
val eventId: Long,
|
||||
val calendarId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
|
||||
ReminderAlert(
|
||||
alertId = c.getLong(0),
|
||||
eventId = c.getLong(1),
|
||||
beginMillis = c.getLong(2),
|
||||
endMillis = c.getLong(3),
|
||||
title = c.getString(4).orEmpty(),
|
||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(6) == 1,
|
||||
calendarId = c.getLong(2),
|
||||
beginMillis = c.getLong(3),
|
||||
endMillis = c.getLong(4),
|
||||
title = c.getString(5).orEmpty(),
|
||||
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||
isAllDay = c.getInt(7) == 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
|
||||
val PROJECTION = arrayOf(
|
||||
CalendarContract.CalendarAlerts._ID,
|
||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||
CalendarContract.CalendarAlerts.BEGIN,
|
||||
CalendarContract.CalendarAlerts.END,
|
||||
CalendarContract.CalendarAlerts.TITLE,
|
||||
|
||||
@@ -14,6 +14,10 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -29,6 +33,8 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -39,15 +45,23 @@ class ReminderNotifier @Inject constructor(
|
||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
|
||||
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()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
val time = reminderTimeText(
|
||||
beginMillis = alert.beginMillis,
|
||||
endMillis = alert.endMillis,
|
||||
isAllDay = alert.isAllDay,
|
||||
zone = ZoneId.systemDefault(),
|
||||
locale = Locale.getDefault(),
|
||||
is24Hour = is24Hour,
|
||||
)
|
||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
@@ -25,6 +26,7 @@ fun reminderTimeText(
|
||||
isAllDay: Boolean,
|
||||
zone: ZoneId,
|
||||
locale: Locale,
|
||||
is24Hour: Boolean,
|
||||
): String {
|
||||
if (isAllDay) {
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
@@ -40,16 +42,18 @@ fun reminderTimeText(
|
||||
}
|
||||
}
|
||||
|
||||
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||
} else {
|
||||
val dateTimeFormat = DateTimeFormatter
|
||||
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||
.withLocale(locale)
|
||||
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
|
||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||
// applies to the time portion too.
|
||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||
dateTime(begin) + RANGE + dateTime(end)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
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(
|
||||
@@ -40,6 +48,14 @@ data class EventInstance(
|
||||
val location: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this event has finished relative to [now] — its end is at or before
|
||||
* the current instant. An in-progress event (already started but not yet ended)
|
||||
* is *not* considered ended. All-day events end at the exclusive next-midnight,
|
||||
* so they only count as ended once their day is fully over.
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -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()
|
||||
@@ -72,6 +72,8 @@ fun CalendarHost(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||
) {
|
||||
// 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.
|
||||
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) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
@@ -171,6 +178,16 @@ fun CalendarHost(
|
||||
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
|
||||
// requested destination (a widget/shortcut/QS-tile launch) is revealed on
|
||||
@@ -196,7 +213,9 @@ fun CalendarHost(
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = req.dateIso
|
||||
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
|
||||
// No widget source (an external date tap) roots over the default
|
||||
// home view, so backing out of the day returns home then exits.
|
||||
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenEvent -> {
|
||||
@@ -210,6 +229,17 @@ fun CalendarHost(
|
||||
detailKey = key
|
||||
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 -> {
|
||||
// External "new event" entries (QS tile / launcher shortcut /
|
||||
// widget) must land on top of whatever is open — the form overlay
|
||||
@@ -258,6 +288,8 @@ fun CalendarHost(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
@@ -267,6 +299,8 @@ fun CalendarHost(
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
@@ -275,6 +309,8 @@ fun CalendarHost(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
@@ -283,6 +319,8 @@ fun CalendarHost(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
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.lifecycle.Lifecycle
|
||||
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.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
@@ -33,6 +35,8 @@ fun RootScreen(
|
||||
onWidgetNavConsumed: () -> Unit = {},
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
@@ -49,6 +53,12 @@ fun RootScreen(
|
||||
hasPermission = ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CALENDAR
|
||||
) == 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)
|
||||
@@ -76,6 +86,8 @@ fun RootScreen(
|
||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
)
|
||||
false -> ReminderOnboardingScreen(
|
||||
onFinished = reminderOnboarding::finish,
|
||||
|
||||
@@ -13,8 +13,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
* detail-key channel and leave the base view untouched.)
|
||||
*/
|
||||
sealed interface WidgetNavRequest {
|
||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
||||
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
|
||||
/**
|
||||
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
|
||||
* [source]. A null [source] means the request came from outside the app (a
|
||||
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
|
||||
* the default home view instead of a widget's view.
|
||||
*/
|
||||
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
|
||||
|
||||
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
||||
data class OpenEvent(
|
||||
@@ -26,4 +31,13 @@ sealed interface WidgetNavRequest {
|
||||
|
||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* How far ahead the agenda (screen or widget) shows events, starting at today.
|
||||
*
|
||||
* Two flavours:
|
||||
* - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days),
|
||||
* [Month] (30 days), [Custom] (1–365 days). These never degenerate.
|
||||
* - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs
|
||||
* through the end of the current week (respecting the week-start preference —
|
||||
* a Monday start means "everything before next Monday"); [ThisMonth] runs
|
||||
* through the last day of the current month. These shrink as the period ends.
|
||||
*/
|
||||
sealed interface AgendaRange {
|
||||
data object Day : AgendaRange
|
||||
data object Week : AgendaRange
|
||||
data object Month : AgendaRange
|
||||
data object ThisWeek : AgendaRange
|
||||
data object ThisMonth : AgendaRange
|
||||
data class Custom(val days: Int) : AgendaRange
|
||||
|
||||
companion object {
|
||||
/** Allowed bounds for a [Custom] day count. */
|
||||
const val MIN_CUSTOM_DAYS = 1
|
||||
const val MAX_CUSTOM_DAYS = 365
|
||||
}
|
||||
}
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* Inclusive number of days the window spans, starting at (and including)
|
||||
* [anchor]. The calendar-aligned ranges depend on the anchor day and, for
|
||||
* [AgendaRange.ThisWeek], the [weekStart] preference.
|
||||
*/
|
||||
fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) {
|
||||
AgendaRange.Day -> 1
|
||||
AgendaRange.Week -> DAYS_PER_WEEK
|
||||
AgendaRange.Month -> 30
|
||||
AgendaRange.ThisWeek -> {
|
||||
// Days elapsed since the week's first day (0 when today *is* the start),
|
||||
// so the window is the rest of the week through the day before it repeats.
|
||||
val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK
|
||||
DAYS_PER_WEEK - sinceWeekStart
|
||||
}
|
||||
AgendaRange.ThisMonth -> {
|
||||
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
|
||||
daysInMonth - anchor.day + 1
|
||||
}
|
||||
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
|
||||
}
|
||||
|
||||
/** Stored representation: a fixed token, or `CUSTOM:<days>`. */
|
||||
fun AgendaRange.storageValue(): String = when (this) {
|
||||
AgendaRange.Day -> "DAY"
|
||||
AgendaRange.Week -> "WEEK"
|
||||
AgendaRange.Month -> "MONTH"
|
||||
AgendaRange.ThisWeek -> "THIS_WEEK"
|
||||
AgendaRange.ThisMonth -> "THIS_MONTH"
|
||||
is AgendaRange.Custom -> "$CUSTOM_PREFIX$days"
|
||||
}
|
||||
|
||||
/** Parse a stored value; unknown/garbage falls back to [default]. */
|
||||
fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when {
|
||||
stored == "DAY" -> AgendaRange.Day
|
||||
stored == "WEEK" -> AgendaRange.Week
|
||||
stored == "MONTH" -> AgendaRange.Month
|
||||
stored == "THIS_WEEK" -> AgendaRange.ThisWeek
|
||||
stored == "THIS_MONTH" -> AgendaRange.ThisMonth
|
||||
stored != null && stored.startsWith(CUSTOM_PREFIX) -> {
|
||||
val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull()
|
||||
if (days != null) {
|
||||
AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS))
|
||||
} else {
|
||||
default
|
||||
}
|
||||
}
|
||||
else -> default
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete span the range covers, starting at [start] through [end]
|
||||
* (inclusive), for a human-readable header:
|
||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
||||
*/
|
||||
fun agendaRangeWindowSummary(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
return when (range) {
|
||||
AgendaRange.Day -> medium.format(javaStart)
|
||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
||||
}
|
||||
}
|
||||
|
||||
private const val CUSTOM_PREFIX = "CUSTOM:"
|
||||
@@ -2,22 +2,26 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -35,9 +39,13 @@ import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -46,18 +54,28 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -79,20 +97,25 @@ fun AgendaScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
|
||||
val isOnToday = when (val s = state) {
|
||||
is AgendaUiState.Success -> s.anchor == s.today
|
||||
else -> true
|
||||
}
|
||||
val successState = state as? AgendaUiState.Success
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -100,6 +123,7 @@ fun AgendaScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -120,7 +144,7 @@ fun AgendaScreen(
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
@@ -135,21 +159,134 @@ fun AgendaScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
AgendaContent(
|
||||
state = state,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
pastDisplay = pastDisplay,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showRangePicker) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.agenda_range_override_hint),
|
||||
selected = successState?.range ?: AgendaRange.Month,
|
||||
onSelect = viewModel::setRangeOverride,
|
||||
onDismiss = { showRangePicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||
* range picker as a session-only override. Filled with the primary container
|
||||
* while an override is active, so the temporary state is obvious.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangePill(
|
||||
range: AgendaRange,
|
||||
isOverride: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
val content = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
color = container,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangeBanner(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val window = agendaRangeWindowSummary(range, start, end, locale)
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_range_showing_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "${agendaRangeLabel(range)}, $window",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
pastDisplay: PastEventDisplay,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -159,19 +296,42 @@ private fun AgendaContent(
|
||||
is AgendaUiState.Failure -> Box(modifier) {
|
||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
}
|
||||
is AgendaUiState.Success ->
|
||||
if (state.days.isEmpty()) {
|
||||
is AgendaUiState.Success -> {
|
||||
val now by rememberCurrentMinute()
|
||||
// Hiding drops finished events — and any day they leave empty; dimming
|
||||
// keeps them but fades the row. Recomputed each minute so events fall
|
||||
// away (or fade) as they end while the screen stays open.
|
||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
state.days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
state.days
|
||||
}
|
||||
if (days.isEmpty()) {
|
||||
AgendaEmpty(modifier)
|
||||
} else {
|
||||
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
||||
AgendaList(
|
||||
days = days,
|
||||
today = state.today,
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AgendaList(
|
||||
state: AgendaUiState.Success,
|
||||
days: List<AgendaDay>,
|
||||
today: LocalDate,
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -180,9 +340,9 @@ private fun AgendaList(
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = state.today)
|
||||
AgendaDayHeader(date = day.date, today = today)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
@@ -191,6 +351,7 @@ private fun AgendaList(
|
||||
AgendaEventRow(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = calendarAnimateItem(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
@@ -223,13 +384,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
private fun AgendaEventRow(
|
||||
event: EventInstance,
|
||||
position: Position,
|
||||
dimmed: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
GroupedRow(
|
||||
modifier = modifier,
|
||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||
title = title,
|
||||
summary = agendaTimeSummary(event),
|
||||
position = position,
|
||||
@@ -254,7 +416,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.EventAvailable,
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
@@ -265,13 +427,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,15 +493,17 @@ private fun agendaTimeSummary(event: EventInstance): String {
|
||||
val time = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||
val is24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant): String {
|
||||
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
|
||||
val t = instant.toLocalDateTime(zone).time
|
||||
return "%02d:%02d".format(t.hour, t.minute)
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||
}
|
||||
|
||||
private fun formatAgendaDate(date: LocalDate): String {
|
||||
|
||||
@@ -53,5 +53,13 @@ sealed interface AgendaUiState {
|
||||
val anchor: LocalDate,
|
||||
val today: LocalDate,
|
||||
val days: List<AgendaDay>,
|
||||
/** The range currently in effect — the saved default or a session override. */
|
||||
val range: AgendaRange,
|
||||
/** True when [range] is a temporary in-view override of the saved default. */
|
||||
val rangeIsOverride: Boolean,
|
||||
/** Last day the current [range] covers (inclusive), for the range header. */
|
||||
val rangeEnd: LocalDate,
|
||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||
val showRangeBar: Boolean,
|
||||
) : AgendaUiState
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -17,10 +20,13 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
@@ -30,16 +36,36 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/** How far ahead the agenda loads events from its anchor day. */
|
||||
internal const val AGENDA_WINDOW_DAYS = 60
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class AgendaViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
// The saved agenda range plus the range-bar visibility toggle.
|
||||
private val agendaSettings = combine(
|
||||
settingsPrefs.agendaScreenRange,
|
||||
settingsPrefs.agendaShowRangeBar,
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
// First day of the week, for the calendar-aligned "this week" range.
|
||||
private val weekStartDay = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
|
||||
/**
|
||||
* How to treat events that already ended today (show / dim / hide). A display
|
||||
* concern only, so it rides alongside the data state rather than re-querying;
|
||||
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
||||
*/
|
||||
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = PastEventDisplay.SHOW,
|
||||
)
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
@@ -48,14 +74,32 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
val anchor: StateFlow<LocalDate> = _anchor
|
||||
|
||||
val state: StateFlow<AgendaUiState> = _anchor
|
||||
.flatMapLatest { anchor ->
|
||||
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
||||
// A transient, in-view override of the saved agenda range. Held in memory
|
||||
// (not persisted), so it survives view switches and rotation within the
|
||||
// session but resets to the saved default when the app is relaunched.
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
rangeIsOverride = override != null && override != settings.range,
|
||||
weekStart = weekStart,
|
||||
showRangeBar = settings.showBar,
|
||||
)
|
||||
}
|
||||
.flatMapLatest { params ->
|
||||
val window = agendaRange(
|
||||
params.anchor,
|
||||
params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||
zone,
|
||||
)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
repository.instances(window),
|
||||
) { calendars, instances ->
|
||||
buildState(anchor, calendars, instances)
|
||||
buildState(params, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
@@ -75,16 +119,47 @@ class AgendaViewModel @Inject constructor(
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
/** Temporarily override the agenda range for this session (the bottom-left pill). */
|
||||
fun setRangeOverride(range: AgendaRange) {
|
||||
_rangeOverride.value = range
|
||||
}
|
||||
|
||||
private data class AgendaSettings(
|
||||
val range: AgendaRange,
|
||||
val showBar: Boolean,
|
||||
)
|
||||
|
||||
private data class AgendaParams(
|
||||
val anchor: LocalDate,
|
||||
val range: AgendaRange,
|
||||
val rangeIsOverride: Boolean,
|
||||
val weekStart: DayOfWeek,
|
||||
val showRangeBar: Boolean,
|
||||
)
|
||||
|
||||
private fun buildState(
|
||||
anchor: LocalDate,
|
||||
params: AgendaParams,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val anchor = params.anchor
|
||||
val days = groupAgendaDays(anchor, instances, zone)
|
||||
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
today = todayDate,
|
||||
days = days,
|
||||
range = params.range,
|
||||
rangeIsOverride = params.rangeIsOverride,
|
||||
rangeEnd = rangeEnd,
|
||||
showRangeBar = params.showRangeBar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,17 @@ import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -26,20 +30,28 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
@@ -61,20 +73,33 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
@@ -82,7 +107,6 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
@@ -104,6 +128,7 @@ fun CalendarsScreen(
|
||||
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
|
||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||
// [editorSession] bumps on every open so the editor's field state resets for
|
||||
@@ -117,7 +142,7 @@ fun CalendarsScreen(
|
||||
sessionKey = editorSession,
|
||||
isNew = editorId == NEW_CALENDAR_ID,
|
||||
initialName = editing?.displayName.orEmpty(),
|
||||
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||
initialDescription = editing?.description.orEmpty(),
|
||||
onSave = { name, color, description ->
|
||||
val id = editorId
|
||||
@@ -144,10 +169,15 @@ fun CalendarsScreen(
|
||||
backupResult = backupResult,
|
||||
onExportBackup = viewModel::exportBackup,
|
||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||
autoBackup = autoBackup,
|
||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
||||
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
||||
onBack = onBack,
|
||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
onSetDisabled = viewModel::setDisabled,
|
||||
onSetAccountDisabled = viewModel::setAccountDisabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -162,13 +192,22 @@ private fun CalendarsList(
|
||||
backupResult: BackupResult?,
|
||||
onExportBackup: (android.net.Uri) -> Unit,
|
||||
onConsumeBackupResult: () -> Unit,
|
||||
autoBackup: AutoBackupUiState,
|
||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||
onSetAutoBackupInterval: (Long) -> Unit,
|
||||
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onSetDisabled: (Long, Boolean) -> Unit,
|
||||
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||
// calendar visible by default, the section is collapsible for tidiness).
|
||||
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
|
||||
var localExpanded by remember { mutableStateOf(true) }
|
||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||
|
||||
LaunchedEffect(error) {
|
||||
@@ -184,6 +223,13 @@ private fun CalendarsList(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let(onExportBackup) }
|
||||
|
||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||
// write grant so background runs can keep writing to it.
|
||||
val pickFolder = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
) { uri -> uri?.let(onSetAutoBackupFolder) }
|
||||
var showInterval by remember { mutableStateOf(false) }
|
||||
|
||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||
LaunchedEffect(backupResult) {
|
||||
when (val r = backupResult) {
|
||||
@@ -208,70 +254,37 @@ private fun CalendarsList(
|
||||
onBack = onBack,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) {
|
||||
// Local (device-only) calendars — the calendars the app owns. The
|
||||
// "Add calendar" entry closes the group as its final row.
|
||||
SectionHeader(stringResource(R.string.calendars_local_header))
|
||||
if (local.isEmpty()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
}
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_disable_hint))
|
||||
val localCount = local.size + 1
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, localCount),
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
)
|
||||
},
|
||||
onClick = { onEdit(calendar) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_add),
|
||||
position = positionOf(local.size, localCount),
|
||||
leading = { AddAvatar() },
|
||||
onClick = onAdd,
|
||||
)
|
||||
|
||||
// Backup — local calendars have no sync, so a .ics export is their only
|
||||
// safety net. Offered only when there is something to back up.
|
||||
if (local.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Synced calendars — read-only, grouped by account, each with a
|
||||
// per-account "manage in source app" link.
|
||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||
HintText(stringResource(R.string.calendars_synced_hint))
|
||||
synced
|
||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||
.forEach { (account, cals) ->
|
||||
AccountHeader(account = account, accountType = cals.first().accountType)
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
// Local (device-only) calendars — one collapsible group. The header's
|
||||
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||
// tapping a calendar row opens its editor.
|
||||
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
|
||||
CalendarGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
expanded = localExpanded,
|
||||
bodyHasRows = local.isNotEmpty(),
|
||||
headerDisabled = localDisabled,
|
||||
leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } },
|
||||
manageIcon = Icons.Default.Add,
|
||||
manageLabel = stringResource(R.string.calendars_add),
|
||||
onManage = onAdd,
|
||||
onToggleExpand = { localExpanded = !localExpanded },
|
||||
showToggleAll = local.isNotEmpty(),
|
||||
allEnabled = local.none { it.id in disabledIds },
|
||||
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
|
||||
) {
|
||||
if (local.isEmpty()) {
|
||||
HintText(stringResource(R.string.calendars_local_empty))
|
||||
} else {
|
||||
local.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = positionOf(index, cals.size),
|
||||
summary = calendar.description,
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
@@ -281,17 +294,118 @@ private fun CalendarsList(
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
)
|
||||
},
|
||||
onClick = { onEdit(calendar) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_add_account),
|
||||
position = Position.Alone,
|
||||
leading = { AddAvatar() },
|
||||
onClick = {
|
||||
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
|
||||
},
|
||||
}
|
||||
|
||||
// Backup — local calendars have no sync, so a .ics export is their only
|
||||
// safety net. Offered only when there is something to back up.
|
||||
if (local.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
// One connected card: the one-time export on top, then automatic
|
||||
// backup (and its folder/interval rows when on).
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_backup_action),
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup),
|
||||
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||
trailing = {
|
||||
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
|
||||
},
|
||||
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
|
||||
)
|
||||
if (autoBackup.enabled) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||
summary = rememberFolderName(autoBackup.folderUri)
|
||||
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||
position = Position.Middle,
|
||||
onClick = { runCatching { pickFolder.launch(null) } },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showInterval = true },
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Synced calendars — read-only, grouped by account. Each account is a
|
||||
// collapsible group whose header opens the source app (icon) and toggles
|
||||
// all of its calendars at once (switch).
|
||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||
HintText(stringResource(R.string.calendars_synced_hint))
|
||||
synced
|
||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||
.forEach { (account, cals) ->
|
||||
val expanded = account !in collapsedAccounts
|
||||
val accountType = cals.first().accountType
|
||||
val accountDisabled = cals.all { it.id in disabledIds }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CalendarGroup(
|
||||
title = account,
|
||||
expanded = expanded,
|
||||
bodyHasRows = true,
|
||||
headerDisabled = accountDisabled,
|
||||
leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } },
|
||||
manageIcon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
manageLabel = stringResource(R.string.calendars_manage_in_app),
|
||||
onManage = {
|
||||
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
||||
},
|
||||
onToggleExpand = {
|
||||
collapsedAccounts = if (expanded) {
|
||||
collapsedAccounts + account
|
||||
} else {
|
||||
collapsedAccounts - account
|
||||
}
|
||||
},
|
||||
showToggleAll = true,
|
||||
allEnabled = cals.none { it.id in disabledIds },
|
||||
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
|
||||
) {
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
val disabled = calendar.id in disabledIds
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showInterval) {
|
||||
BackupIntervalDialog(
|
||||
currentMinutes = autoBackup.intervalMinutes,
|
||||
onConfirm = onSetAutoBackupInterval,
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -393,7 +507,7 @@ private fun CalendarEditor(
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ColorSwatchRow(
|
||||
colors = CALENDAR_COLOR_PALETTE,
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = color,
|
||||
onSelect = { color = it },
|
||||
dark = dark,
|
||||
@@ -498,32 +612,178 @@ private fun EditorCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible calendar group rendered as a connected card. The header row is
|
||||
* the card's top (tap to expand/collapse): a [leading] source mark (the account
|
||||
* app's logo, or a device chip for local), the group [title] and a trailing
|
||||
* overflow (⋮) menu holding the account-level actions — enable/disable every
|
||||
* calendar at once (when [showToggleAll]) and the manage/add action
|
||||
* ([manageIcon] / [manageLabel] → [onManage]). The group's calendars render as
|
||||
* the rows below via [body].
|
||||
*
|
||||
* The header keeps the standard row colour; the calendars it reveals sit one tone
|
||||
* darker, so the group reads as a header over nested content. Once [headerDisabled]
|
||||
* (every calendar switched off) the header fades to the disabled emphasis so the
|
||||
* whole group reads as off — not just via the menu. [bodyHasRows] is false when no
|
||||
* calendar rows follow (e.g. an empty local group), so the header stays a
|
||||
* standalone card rather than a top edge with nothing beneath it.
|
||||
*/
|
||||
@Composable
|
||||
private fun AccountHeader(account: String, accountType: String) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
private fun CalendarGroup(
|
||||
title: String,
|
||||
expanded: Boolean,
|
||||
bodyHasRows: Boolean,
|
||||
headerDisabled: Boolean,
|
||||
leading: @Composable () -> Unit,
|
||||
manageIcon: ImageVector,
|
||||
manageLabel: String,
|
||||
onManage: () -> Unit,
|
||||
onToggleExpand: () -> Unit,
|
||||
showToggleAll: Boolean,
|
||||
allEnabled: Boolean,
|
||||
onToggleAll: (Boolean) -> Unit,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
position = if (expanded && bodyHasRows) Position.Top else Position.Alone,
|
||||
dimmed = headerDisabled,
|
||||
leading = leading,
|
||||
trailing = {
|
||||
CalendarGroupMenu(
|
||||
title = title,
|
||||
showToggleAll = showToggleAll,
|
||||
allEnabled = allEnabled,
|
||||
onToggleAll = onToggleAll,
|
||||
manageIcon = manageIcon,
|
||||
manageLabel = manageLabel,
|
||||
onManage = onManage,
|
||||
)
|
||||
},
|
||||
onClick = onToggleExpand,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
Text(
|
||||
text = account,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
OutlinedButton(onClick = {
|
||||
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
||||
}) {
|
||||
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.calendars_manage_in_app))
|
||||
Column(content = body)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account header's overflow (⋮) menu: the two account-level actions that
|
||||
* don't fit on one line — "Enable/Disable all" (when [showToggleAll]) and the
|
||||
* manage/add action. A rounded, tonal dropdown matching the app's surfaces, with
|
||||
* the two actions divided for clear separation.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarGroupMenu(
|
||||
title: String,
|
||||
showToggleAll: Boolean,
|
||||
allEnabled: Boolean,
|
||||
onToggleAll: (Boolean) -> Unit,
|
||||
manageIcon: ImageVector,
|
||||
manageLabel: String,
|
||||
onManage: () -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { open = true }) {
|
||||
Icon(
|
||||
Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.calendars_account_menu_a11y, title),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = open,
|
||||
onDismissRequest = { open = false },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
// A distinct tone + a lifted shadow so the menu reads as floating
|
||||
// above the cards (which sit at surfaceContainerHigh) rather than
|
||||
// blending into them.
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 6.dp,
|
||||
) {
|
||||
if (showToggleAll) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (allEnabled) R.string.calendars_disable_all
|
||||
else R.string.calendars_enable_all,
|
||||
),
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (allEnabled) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
open = false
|
||||
onToggleAll(!allEnabled)
|
||||
},
|
||||
)
|
||||
HorizontalDivider(Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(manageLabel) },
|
||||
leadingIcon = { Icon(manageIcon, contentDescription = null) },
|
||||
onClick = {
|
||||
open = false
|
||||
onManage()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */
|
||||
/**
|
||||
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
|
||||
* round 40dp chip, so each synced account is recognisable at a glance. We load
|
||||
* whatever app owns the account from [PackageManager] rather than bundling brand
|
||||
* logos — always accurate, nothing to license. Falls back to a neutral cloud
|
||||
* chip when no installed app resolves for the account.
|
||||
*/
|
||||
@Composable
|
||||
private fun SourceLogo(accountType: String) {
|
||||
val context = LocalContext.current
|
||||
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
|
||||
if (logo != null) {
|
||||
Image(
|
||||
bitmap = logo,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
} else {
|
||||
LeadingAvatar(Icons.Default.Cloud)
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||
val pm = context.packageManager
|
||||
val candidates = buildList {
|
||||
curatedSourcePackage(accountType)?.let { add(it) }
|
||||
AccountManager.get(context).authenticatorTypes
|
||||
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||
?.packageName
|
||||
?.let { add(it) }
|
||||
}
|
||||
for (pkg in candidates) {
|
||||
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||
if (bitmap != null) return bitmap.asImageBitmap()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||
@Composable
|
||||
private fun LeadingAvatar(icon: ImageVector) {
|
||||
Box(
|
||||
@@ -542,25 +802,6 @@ private fun LeadingAvatar(icon: ImageVector) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
||||
@Composable
|
||||
private fun AddAvatar() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
@@ -581,6 +822,95 @@ private fun HintText(text: String) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||
@Composable
|
||||
private fun rememberFolderName(uriString: String?): String? {
|
||||
val context = LocalContext.current
|
||||
return remember(uriString) {
|
||||
uriString?.let {
|
||||
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||
@Composable
|
||||
private fun backupIntervalLabel(minutes: Long): String {
|
||||
val duration = when {
|
||||
minutes % MINUTES_PER_WEEK == 0L ->
|
||||
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||
minutes % MINUTES_PER_DAY == 0L ->
|
||||
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||
minutes % 60L == 0L ->
|
||||
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||
else ->
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||
}
|
||||
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||
}
|
||||
|
||||
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||
@Composable
|
||||
private fun backupStatusText(status: BackupStatus): String {
|
||||
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||
val relative = DateUtils.getRelativeTimeSpanString(
|
||||
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||
).toString()
|
||||
return if (status.lastSuccess) {
|
||||
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||
} else {
|
||||
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||
}
|
||||
}
|
||||
|
||||
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||
@Composable
|
||||
private fun BackupIntervalDialog(
|
||||
currentMinutes: Long,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||
onDismiss()
|
||||
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440L
|
||||
private const val MINUTES_PER_WEEK = 10_080L
|
||||
|
||||
/**
|
||||
* Pick the app to open for managing a synced calendar's account. The account's
|
||||
* own authenticator package (resolved from [AccountManager], no permission
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package de.jeanlucmakiola.calendula.ui.calendars
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -16,6 +24,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -33,9 +42,13 @@ import javax.inject.Inject
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CalendarsViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val suppressedStore: SuppressedReminderStore,
|
||||
private val notifier: ReminderNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -63,6 +76,22 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||
settingsPrefs.autoBackupEnabled,
|
||||
settingsPrefs.autoBackupIntervalMinutes,
|
||||
settingsPrefs.autoBackupFolderUri,
|
||||
settingsPrefs.autoBackupStatus,
|
||||
) { enabled, interval, folder, status ->
|
||||
AutoBackupUiState(enabled, interval, folder, status)
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AutoBackupUiState(),
|
||||
)
|
||||
|
||||
private val _error = MutableStateFlow(false)
|
||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||
|
||||
@@ -119,10 +148,92 @@ class CalendarsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable every calendar of one account in a single write — the
|
||||
* "toggle all" affordance on an account header. Done as one set update so the
|
||||
* per-calendar [setDisabled] calls can't race each other.
|
||||
*/
|
||||
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + ids else current - ids.toSet()
|
||||
if (next != current) {
|
||||
prefs.setDisabledCalendarIds(next)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
fun setAutoBackupEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsPrefs.setAutoBackupEnabled(enabled)
|
||||
reschedule()
|
||||
// Give immediate feedback when turning it on with a folder already set.
|
||||
if (enabled && settingsPrefs.autoBackupFolderUri.first() != null) {
|
||||
BackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||
viewModelScope.launch {
|
||||
settingsPrefs.setAutoBackupIntervalMinutes(minutes)
|
||||
reschedule()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the chosen destination folder (taking a durable write grant) and run once. */
|
||||
fun setAutoBackupFolder(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
settingsPrefs.setAutoBackupFolderUri(uri.toString())
|
||||
reschedule()
|
||||
if (settingsPrefs.autoBackupEnabled.first()) BackupScheduler.runNow(context)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reschedule() {
|
||||
BackupScheduler.apply(
|
||||
context = context,
|
||||
enabled = settingsPrefs.autoBackupEnabled.first(),
|
||||
intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(),
|
||||
hasFolder = settingsPrefs.autoBackupFolderUri.first() != null,
|
||||
)
|
||||
}
|
||||
|
||||
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -136,6 +247,14 @@ class CalendarsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Automatic-backup settings + last-run status for the Backup section. */
|
||||
data class AutoBackupUiState(
|
||||
val enabled: Boolean = false,
|
||||
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
|
||||
val folderUri: String? = null,
|
||||
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
|
||||
)
|
||||
|
||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||
sealed interface BackupResult {
|
||||
data class Success(val eventCount: Int) : BackupResult
|
||||
|
||||
@@ -59,6 +59,7 @@ fun CalendarDrawer(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -73,10 +74,10 @@ fun CalendarDrawer(
|
||||
DrawerHeader()
|
||||
|
||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||
viewOrder.forEachIndexed { index, view ->
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||
position = positionOf(index, viewOrder.size),
|
||||
selected = view == currentView,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(view.icon, contentDescription = null) },
|
||||
|
||||
@@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||
|
||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||
if (available.isEmpty()) return 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]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* sits at the bottom. Pressing back pops one level until only the home view
|
||||
|
||||
@@ -67,16 +67,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() }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/**
|
||||
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||
*
|
||||
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||
* so it overlays the UI.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.DebugRibbon() {
|
||||
Text(
|
||||
text = "DEBUG",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.zIndex(1f)
|
||||
// Push the band out so its midline crosses the very corner, then
|
||||
// rotate it to the classic 45° ribbon.
|
||||
.offset(x = 36.dp, y = 24.dp)
|
||||
.rotate(45f)
|
||||
.background(Color(0xFFB23B00))
|
||||
.width(140.dp)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
|
||||
/**
|
||||
* The shared label and icon for each optional [EventFormField]. Both the event
|
||||
* editor (which shows these fields) and the settings screen (which toggles their
|
||||
* defaults) draw from this one mapping, so a field reads the same wherever it
|
||||
* appears.
|
||||
*/
|
||||
@StringRes
|
||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||
EventFormField.Color -> R.string.event_edit_color
|
||||
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||
}
|
||||
|
||||
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Location -> Icons.Default.Place
|
||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
EventFormField.Visibility -> Icons.Default.Lock
|
||||
EventFormField.Color -> Icons.Default.Palette
|
||||
EventFormField.Attendees -> Icons.Default.People
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -57,10 +58,16 @@ fun positionOf(index: Int, count: Int): Position = when {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The app's standard full-screen list scaffold. By default a collapsing
|
||||
* [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
|
||||
* the content scrolls — used by Settings and the calendar manager, where the
|
||||
* large header sets the page. Content is a scrollable column that feeds the
|
||||
* toolbar via nested scroll.
|
||||
*
|
||||
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
|
||||
* title sits in the bar from the start, with no expanded header to scroll past.
|
||||
* Preferred for selection pickers, where the tall header is just wasted space
|
||||
* above the options.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -68,12 +75,16 @@ fun CollapsingScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
largeTopBar: Boolean = true,
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val scrollBehavior =
|
||||
val scrollBehavior = if (largeTopBar) {
|
||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
} else {
|
||||
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.predictiveBack(onBack = onBack)
|
||||
@@ -81,22 +92,34 @@ fun CollapsingScaffold(
|
||||
.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,
|
||||
),
|
||||
val navigationIcon = @Composable {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
val colors = TopAppBarDefaults.topAppBarColors(
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
if (largeTopBar) {
|
||||
LargeTopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = colors,
|
||||
)
|
||||
} else {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = snackbarHost,
|
||||
) { innerPadding ->
|
||||
@@ -140,7 +163,11 @@ fun GroupedRow(
|
||||
summary: String? = null,
|
||||
selected: Boolean = false,
|
||||
dimmed: Boolean = false,
|
||||
container: Color? = null,
|
||||
minHeight: Dp = 72.dp,
|
||||
// The 2.dp separation between rows in a run. Suppressed by the reorderable
|
||||
// list, which owns uniform spacing itself so every slot has the same pitch.
|
||||
gapBelow: Boolean = true,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
@@ -159,9 +186,10 @@ fun GroupedRow(
|
||||
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 gap = when {
|
||||
!gapBelow -> Modifier
|
||||
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
else -> Modifier
|
||||
}
|
||||
val itemColors = if (selected) {
|
||||
ListItemDefaults.colors(
|
||||
@@ -198,10 +226,10 @@ fun GroupedRow(
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.then(gap)
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val containerColor = when {
|
||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||
container != null -> container
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Whether the week/day timeline draws an hour separator line, from the
|
||||
* `showHourLines` preference. Provided once at the app root (like
|
||||
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
|
||||
* Defaults to off — the historical clean look.
|
||||
*/
|
||||
val LocalShowHourLines = staticCompositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* Draw a faint separator line at the top of each hour (1..23) when [show] is
|
||||
* true. Applied to a day column's content so each line sits over the column's
|
||||
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
|
||||
* height; [color] is resolved by the caller from the theme.
|
||||
*/
|
||||
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
||||
if (!show) {
|
||||
this
|
||||
} else {
|
||||
drawBehind {
|
||||
for (hour in 1 until 24) {
|
||||
val y = hour * hourHeightPx
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(size.width, y),
|
||||
strokeWidth = 1f,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ fun InlineTextField(
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||
imeAction: ImeAction = ImeAction.Default,
|
||||
@@ -43,15 +44,17 @@ fun InlineTextField(
|
||||
onImeAction: (() -> Unit)? = null,
|
||||
) {
|
||||
val resolvedStyle = textStyle.copy(
|
||||
color = if (textStyle.color.isSpecified) {
|
||||
textStyle.color
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
color = when {
|
||||
// A disabled field reads as dimmed, like a locked value.
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
textStyle.color.isSpecified -> textStyle.color
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
textStyle = resolvedStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
|
||||
@@ -34,7 +34,7 @@ private val LineThickness = 2.dp
|
||||
* the middle of a minute. Drives the "now" line in the day/week grids.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberCurrentMinute(): State<Instant> {
|
||||
fun rememberCurrentMinute(): State<Instant> {
|
||||
val instant = remember { mutableStateOf(Clock.System.now()) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The cut-off instant before which a calendar event counts as "completed" and is
|
||||
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
||||
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
||||
* dims). Provided per grid screen so only the event chips that read it recompose
|
||||
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
||||
*/
|
||||
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||
|
||||
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||
const val EventDimAlpha = 0.4f
|
||||
@@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -22,14 +24,17 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -37,11 +42,13 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
|
||||
/**
|
||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||
* 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.
|
||||
* reuses the app's [CollapsingScaffold] (back button + full width), but with a
|
||||
* pinned single-line title rather than the large collapsing header — a picker is
|
||||
* a short list, so the tall header would only be empty space to scroll past.
|
||||
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||
*/
|
||||
@Composable
|
||||
@@ -66,7 +73,12 @@ fun FullScreenPicker(
|
||||
(view.parent as? DialogWindowProvider)?.window
|
||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
}
|
||||
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
||||
CollapsingScaffold(
|
||||
title = title,
|
||||
onBack = onDismiss,
|
||||
largeTopBar = false,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +95,10 @@ fun <T> OptionPicker(
|
||||
label: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
) {
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
header?.invoke(this)
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
@@ -106,10 +120,19 @@ fun <T> OptionPicker(
|
||||
}
|
||||
|
||||
/**
|
||||
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
||||
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
||||
* a "Custom" row that expands an inline number field plus a segmented unit
|
||||
* selector. Returns the choice as a [CalendarReminderOverride].
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||
* "on the day"). Two exclusive checkmark rows sit above the list: an explicit
|
||||
* "No reminder" (both pickers, so the empty state stays visible and reachable)
|
||||
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||
* defers to the global default. Clearing the last checked time reverts to "Use
|
||||
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
||||
* wipe the calendar's default) and to explicit [CalendarReminderOverride.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
|
||||
fun ReminderDefaultPicker(
|
||||
@@ -120,57 +143,91 @@ fun ReminderDefaultPicker(
|
||||
onSelect: (CalendarReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
||||
// Optimistic local state: once the user edits, the chosen override is
|
||||
// authoritative while the picker is open, so quick successive toggles compose
|
||||
// on each other instead of racing the round-trip through the settings flow
|
||||
// (which would drop a toggle made before the previous write echoes back).
|
||||
var current by remember { mutableStateOf(selected) }
|
||||
// Whether the user has toggled anything yet. Until then, [current] keeps
|
||||
// mirroring [selected]: a picker that first composed against the settings
|
||||
// flow's initialValue (empty — reachable after process-death restore straight
|
||||
// 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 CalendarReminderOverride.Inherit
|
||||
val isNone = current is CalendarReminderOverride.None
|
||||
val selectedMinutes = (current as? CalendarReminderOverride.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 amountText by rememberSaveable { mutableStateOf(seed.first) }
|
||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
|
||||
val options = buildList {
|
||||
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
||||
add(CalendarReminderOverride.None)
|
||||
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
||||
fun apply(override: CalendarReminderOverride) {
|
||||
userEdited = true
|
||||
current = override
|
||||
onSelect(override)
|
||||
}
|
||||
val rowCount = options.size + 1 // + the custom row
|
||||
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
|
||||
fun toggle(minute: Int) =
|
||||
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
// Exclusive choices in their own group above the multi-select list, since
|
||||
// each is mutually exclusive with picking specific times: "use default"
|
||||
// (per-calendar only) and an explicit "no reminder" (both pickers, so the
|
||||
// empty state stays visible and deliberately reachable).
|
||||
if (allowInherit) {
|
||||
GroupedRow(
|
||||
title = reminderOverrideLabel(option),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
title = stringResource(R.string.reminder_use_default),
|
||||
position = Position.Top,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.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(CalendarReminderOverride.None) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
val rowCount = rows.size + 1 // + the custom row
|
||||
rows.forEachIndexed { index, minute ->
|
||||
val checked = minute in selectedMinutes
|
||||
GroupedRow(
|
||||
title = reminderLeadTimeLabel(minute),
|
||||
position = positionOf(index, rowCount),
|
||||
selected = checked,
|
||||
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||
onClick = { toggle(minute) },
|
||||
)
|
||||
}
|
||||
// When expanded, the Custom row connects downward into the editor card
|
||||
// so the two read as one grouped container (the per-calendar pattern).
|
||||
GroupedRow(
|
||||
title = if (customSelected) {
|
||||
stringResource(
|
||||
R.string.reminder_custom_with_value,
|
||||
reminderLeadTimeLabel(selectedMinutes!!),
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.event_edit_reminder_custom)
|
||||
},
|
||||
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
title = stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
@@ -184,14 +241,36 @@ fun ReminderDefaultPicker(
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
onConfirm = { minutes ->
|
||||
onSelect(CalendarReminderOverride.Minutes(minutes))
|
||||
onDismiss()
|
||||
emit(selectedMinutes + minutes)
|
||||
amountText = ""
|
||||
customExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The override a lead-time set maps to when emitted from [ReminderDefaultPicker].
|
||||
* A non-empty set is [CalendarReminderOverride.Minutes]; an empty set (the last
|
||||
* time was unchecked) reverts to [CalendarReminderOverride.Inherit] on a
|
||||
* per-calendar picker ([allowInherit]) so an accidental toggle-undo can't
|
||||
* silently wipe the calendar's default, and to explicit
|
||||
* [CalendarReminderOverride.None] on the global default (where empty legitimately
|
||||
* means no reminder). Pure so it can be unit-tested.
|
||||
*/
|
||||
internal fun reminderOverrideForMinutes(
|
||||
minutes: List<Int>,
|
||||
allowInherit: Boolean,
|
||||
): CalendarReminderOverride {
|
||||
val norm = minutes.distinct().sorted()
|
||||
return when {
|
||||
norm.isNotEmpty() -> CalendarReminderOverride.Minutes(norm)
|
||||
allowInherit -> CalendarReminderOverride.Inherit
|
||||
else -> CalendarReminderOverride.None
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||
* row above it (matching the grouped-row system, so the two read as one
|
||||
@@ -260,6 +339,17 @@ private fun CustomReminderEditor(
|
||||
}
|
||||
}
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
@Composable
|
||||
private fun PickerDescription(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedCheck() {
|
||||
Icon(
|
||||
@@ -269,18 +359,146 @@ private fun SelectedCheck() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||
*/
|
||||
@Composable
|
||||
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||
fun AgendaRangePicker(
|
||||
title: String,
|
||||
description: String,
|
||||
selected: AgendaRange,
|
||||
onSelect: (AgendaRange) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth)
|
||||
val rolling = listOf(AgendaRange.Week, AgendaRange.Month)
|
||||
val rollingRowCount = rolling.size + 1 // + the custom row
|
||||
val customSelected = selected is AgendaRange.Custom
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable {
|
||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||
}
|
||||
|
||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = agendaRangeLabel(option),
|
||||
position = position,
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
PickerDescription(description)
|
||||
|
||||
// Calendar-aligned windows.
|
||||
calendarAligned.forEachIndexed { index, option ->
|
||||
rangeRow(option, positionOf(index, calendarAligned.size))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Rolling windows + custom.
|
||||
rolling.forEachIndexed { index, option ->
|
||||
rangeRow(option, positionOf(index, rollingRowCount))
|
||||
}
|
||||
GroupedRow(
|
||||
title = if (customSelected) {
|
||||
agendaRangeLabel(selected)
|
||||
} else {
|
||||
stringResource(R.string.agenda_range_custom)
|
||||
},
|
||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { customExpanded = !customExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
) {
|
||||
CustomDaysEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
onConfirm = { days ->
|
||||
onSelect(AgendaRange.Custom(days))
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
||||
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
||||
minutes == null -> "" to ReminderUnit.Minutes
|
||||
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
||||
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
||||
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
||||
else -> minutes.toString() to ReminderUnit.Minutes
|
||||
/** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */
|
||||
@Composable
|
||||
private fun CustomDaysEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val days = amountText.toIntOrNull()
|
||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = onAmountChange,
|
||||
placeholder = "30",
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text(
|
||||
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||
?: stringResource(R.string.agenda_range_custom_hint),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { days?.let(onConfirm) },
|
||||
enabled = days != null,
|
||||
) {
|
||||
Text(stringResource(R.string.reminder_custom_set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||
@Composable
|
||||
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||
AgendaRange.Day -> stringResource(R.string.agenda_range_day)
|
||||
AgendaRange.ThisWeek -> stringResource(R.string.agenda_range_this_week)
|
||||
AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month)
|
||||
AgendaRange.Week -> stringResource(R.string.agenda_range_week)
|
||||
AgendaRange.Month -> stringResource(R.string.agenda_range_month)
|
||||
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
||||
val ReorderableRowHeight: Dp = 64.dp
|
||||
private val RowGap: Dp = 2.dp
|
||||
|
||||
/**
|
||||
* A vertical list whose rows can be dragged into a new order by their handle.
|
||||
*
|
||||
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
|
||||
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
|
||||
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
|
||||
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
||||
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
||||
* been dragged. The held row follows the finger while the others slide out of
|
||||
* its way (animated); on release the order is committed immediately with a
|
||||
* single [onReorder] call, then the held row eases into its new slot as a
|
||||
* purely visual settle — safe to interrupt with another drag, since the
|
||||
* commit itself never waits on it.
|
||||
*
|
||||
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
||||
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
||||
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> ReorderableColumn(
|
||||
items: List<T>,
|
||||
keyOf: (T) -> Any,
|
||||
onReorder: (List<T>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
|
||||
) {
|
||||
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Local working copy; re-seeded when the incoming list changes (including the
|
||||
// echo of our own committed order).
|
||||
var order by remember(items) { mutableStateOf(items) }
|
||||
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
||||
// Live translation of the held row from its slot (px); also drives the
|
||||
// release settle — re-based onto the row's new slot once the order commits
|
||||
// (see onDragEnd), then eased down to zero.
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
||||
// Purely visual — the order commit (see onDragEnd) never depends on it.
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||
// Whole slots dragged → the slot the held row currently hovers over. Derived
|
||||
// so recomposition only fires when the *target slot* actually changes, not on
|
||||
// every dragged pixel: dragOffset moves every frame during a drag, but the
|
||||
// held row's own translation is already applied in the draw phase via
|
||||
// graphicsLayer below, so only the neighbours' shift (which depends on this)
|
||||
// needs to recompose, and only when a slot boundary is actually crossed.
|
||||
// (Read as a plain val, not `by`, below: a delegated property has a custom
|
||||
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
|
||||
// the loop, so the derived value is captured into a real local instead.)
|
||||
val targetIndex = remember(items, pitchPx) {
|
||||
derivedStateOf {
|
||||
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
|
||||
}
|
||||
}.value
|
||||
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
||||
order.forEachIndexed { index, item ->
|
||||
val key = keyOf(item)
|
||||
val isDragged = key == draggedKey
|
||||
|
||||
// Slide neighbours by one pitch to open the gap the held row will drop
|
||||
// into. Snap (not animate) once idle, so committing the new order — which
|
||||
// moves each row's slot — doesn't visibly fight a lingering animation.
|
||||
val shift = when {
|
||||
draggedIndex == null || targetIndex == null || isDragged -> 0f
|
||||
index in (draggedIndex + 1)..targetIndex -> -pitchPx
|
||||
index in targetIndex until draggedIndex -> pitchPx
|
||||
else -> 0f
|
||||
}
|
||||
val animatedShift by animateFloatAsState(
|
||||
targetValue = shift,
|
||||
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
|
||||
label = "reorderShift",
|
||||
)
|
||||
|
||||
val dragHandle = Modifier.pointerInput(key) {
|
||||
detectDragGestures(
|
||||
onDragStart = {
|
||||
settleJob?.cancel()
|
||||
draggedKey = key
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDrag = { change, amount ->
|
||||
change.consume()
|
||||
dragOffset += amount.y
|
||||
},
|
||||
onDragEnd = {
|
||||
val from = order.indexOfFirst { keyOf(it) == key }
|
||||
if (from < 0) return@detectDragGestures
|
||||
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||
if (to != from) {
|
||||
// Commit synchronously and unconditionally, before the settle
|
||||
// animation below runs — the commit must not depend on that
|
||||
// coroutine reaching its end, or a new drag starting within the
|
||||
// ~160ms settle window would cancel it and silently revert an
|
||||
// already-finished reorder.
|
||||
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
||||
onReorder(order)
|
||||
// The row now lays out at slot `to` instead of `from`; re-base
|
||||
// the live offset onto that new slot (same visual position,
|
||||
// expressed relative to the new one) so the settle below eases
|
||||
// it the rest of the way instead of jumping.
|
||||
dragOffset -= (to - from) * pitchPx
|
||||
}
|
||||
settleJob = scope.launch {
|
||||
// Purely visual from here: ease the held row onto its slot, then
|
||||
// release the drag state. Safe to cancel — the order was already
|
||||
// committed above.
|
||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||
draggedKey = null
|
||||
dragOffset = 0f
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
settleJob = scope.launch {
|
||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||
draggedKey = null
|
||||
dragOffset = 0f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.height(ReorderableRowHeight)
|
||||
.zIndex(if (isDragged) 1f else 0f)
|
||||
.graphicsLayer {
|
||||
translationY = if (isDragged) dragOffset else animatedShift
|
||||
if (isDragged) {
|
||||
scaleX = 1.02f
|
||||
scaleY = 1.02f
|
||||
shadowElevation = 8.dp.toPx()
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
clip = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
|
||||
* `false` = 12-hour ("2:00 PM"). Provided once at the app root from the
|
||||
* `TimeFormatPref` preference resolved against the device's 24-hour system
|
||||
* setting, so every time label reads it without per-screen plumbing. Defaults
|
||||
* to 24-hour for previews and any composition that forgets to provide it.
|
||||
*/
|
||||
val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
||||
|
||||
private const val PATTERN_24 = "HH:mm"
|
||||
private const val PATTERN_12 = "h:mm a"
|
||||
private const val HOUR_PATTERN_12 = "h a"
|
||||
|
||||
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern(if (is24Hour) PATTERN_24 else PATTERN_12, locale)
|
||||
|
||||
/**
|
||||
* Format a wall-clock time (hour 0..23, minute 0..59) honouring the resolved
|
||||
* [is24Hour] convention and [locale]: 24h → "14:00", 12h → "2:00 PM". Pure, so
|
||||
* it can be unit-tested and used off the main thread (widget, notifications).
|
||||
*/
|
||||
fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
|
||||
.format(timeOfDayFormatter(is24Hour, locale))
|
||||
|
||||
/**
|
||||
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
|
||||
* 1440 renders as "24:00" in 24h (its established reading) and as midnight
|
||||
* ("12:00 AM") in 12h, which has no 24:00 equivalent.
|
||||
*/
|
||||
fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String = when {
|
||||
minutes >= MINUTES_PER_DAY && is24Hour -> "24:00"
|
||||
minutes >= MINUTES_PER_DAY -> formatTimeOfDay(0, 0, is24Hour = false, locale)
|
||||
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||
* the prior look); 12h → "1 PM".
|
||||
*/
|
||||
fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
if (is24Hour) {
|
||||
"%02d".format(hour)
|
||||
} else {
|
||||
LocalTime.of(hour.coerceIn(0, 23), 0)
|
||||
.format(DateTimeFormatter.ofPattern(HOUR_PATTERN_12, locale))
|
||||
}
|
||||
|
||||
private const val MINUTES_PER_DAY = 1_440
|
||||
@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
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.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -27,10 +22,13 @@ fun TimePickerAlert(
|
||||
onConfirm: (LocalTime) -> 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(
|
||||
initialHour = initial.hour,
|
||||
initialMinute = initial.minute,
|
||||
is24Hour = deviceUses24HourClock(LocalContext.current),
|
||||
is24Hour = LocalUse24HourFormat.current,
|
||||
)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -45,24 +43,3 @@ fun TimePickerAlert(
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
@@ -80,7 +81,12 @@ import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -111,6 +117,8 @@ fun DayScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
@@ -170,6 +178,7 @@ fun DayScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = date,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -191,7 +200,7 @@ fun DayScreen(
|
||||
DayTopBar(
|
||||
date = date,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
@@ -466,6 +475,8 @@ private fun Timeline(
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||
@@ -488,7 +499,7 @@ private fun Timeline(
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = "%02d".format(h),
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
@@ -534,6 +545,8 @@ private fun DayColumnCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
Card(
|
||||
// Plain rectangular column — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -546,6 +559,9 @@ private fun DayColumnCard(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Faint hour separators sit over the column background but under
|
||||
// the event blocks (drawBehind paints before the children).
|
||||
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||
// Tap an empty slot to create an event there. Taps on event
|
||||
// blocks are consumed by their own click handler first, so this
|
||||
// only fires on the column background. Snaps to the tapped hour.
|
||||
@@ -589,7 +605,10 @@ private fun EventBlock(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -635,8 +654,8 @@ private fun DayLoading() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun minToHm(min: Int): String =
|
||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatDayTitle(date: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
@@ -95,6 +95,8 @@ import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
@@ -248,14 +250,18 @@ fun EventDetailScreen(
|
||||
contentDescription = stringResource(R.string.event_detail_edit),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onDeleteClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
)
|
||||
// Managed special-dates events have no delete — the sync
|
||||
// would resurrect them; the date is removed at the contact.
|
||||
if (!s.isManaged) {
|
||||
IconButton(
|
||||
onClick = onDeleteClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -781,7 +787,7 @@ private fun formatWhen(
|
||||
val zid = ZoneId.of(zone.id)
|
||||
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
|
||||
|
||||
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||
|
||||
@@ -15,6 +15,12 @@ sealed interface EventDetailUiState {
|
||||
val calendarName: String?,
|
||||
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ class EventDetailViewModel @Inject constructor(
|
||||
detail = corrected,
|
||||
calendarName = calendar?.displayName,
|
||||
canModify = calendar?.canModifyContents == true,
|
||||
isManaged = calendar?.isManaged == true,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -37,13 +37,13 @@ import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
@@ -82,6 +82,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
@@ -118,11 +120,16 @@ import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
@@ -132,6 +139,8 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
@@ -430,6 +439,10 @@ private fun EventEditContent(
|
||||
val form = state.form
|
||||
val locale = currentLocale()
|
||||
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 showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -476,6 +489,17 @@ private fun EventEditContent(
|
||||
?: MaterialTheme.colorScheme.primary
|
||||
val gap = 12.dp
|
||||
|
||||
// Issue #10: on a fresh create form (setting on, nothing typed yet) focus the
|
||||
// title and raise the keyboard so the user can type straight away. Keyed on
|
||||
// Unit so it runs once per open — not on every keystroke — and guarded so
|
||||
// editing or an imported/prefilled form never grabs focus.
|
||||
val titleFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) {
|
||||
runCatching { titleFocusRequester.requestFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
// Per-event colour applicability for the resolved calendar:
|
||||
// - palette calendars (Google, …) and local calendars always support it;
|
||||
// - synced calendars with no palette only when the user opted in, and even
|
||||
@@ -503,6 +527,11 @@ private fun EventEditContent(
|
||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||
textStyle = MaterialTheme.typography.headlineMedium
|
||||
.copy(fontWeight = FontWeight.SemiBold),
|
||||
enabled = !locked,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(titleFocusRequester),
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
@@ -511,6 +540,17 @@ private fun EventEditContent(
|
||||
.height(3.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))
|
||||
|
||||
@@ -529,7 +569,11 @@ private fun EventEditContent(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay)
|
||||
Switch(
|
||||
checked = form.isAllDay,
|
||||
onCheckedChange = viewModel::setAllDay,
|
||||
enabled = !locked,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
@@ -541,6 +585,7 @@ private fun EventEditContent(
|
||||
locale = locale,
|
||||
onDateClick = { picker = PickerTarget.StartDate },
|
||||
onTimeClick = { picker = PickerTarget.StartTime },
|
||||
enabled = !locked,
|
||||
)
|
||||
ScheduleRow(
|
||||
label = stringResource(R.string.event_edit_ends),
|
||||
@@ -550,6 +595,7 @@ private fun EventEditContent(
|
||||
onDateClick = { picker = PickerTarget.EndDate },
|
||||
onTimeClick = { picker = PickerTarget.EndTime },
|
||||
isError = EventFormProblem.EndBeforeStart in state.problems,
|
||||
enabled = !locked,
|
||||
)
|
||||
if (EventFormProblem.EndBeforeStart in state.problems) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
@@ -680,7 +726,7 @@ private fun EventEditContent(
|
||||
EditCard(
|
||||
icon = Icons.Default.Repeat,
|
||||
iconContentDescription = null,
|
||||
onClick = { showRecurrencePicker = true },
|
||||
onClick = if (locked) null else ({ showRecurrencePicker = true }),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -923,7 +969,7 @@ private fun EventEditContent(
|
||||
}
|
||||
|
||||
if (showCalendarPicker) {
|
||||
CalendarPickerDialog(
|
||||
CalendarPicker(
|
||||
calendars = state.calendars,
|
||||
selectedId = form.calendarId,
|
||||
onSelect = {
|
||||
@@ -1017,9 +1063,9 @@ private fun FieldPickerDialog(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
hiddenFields.forEach { field ->
|
||||
OptionCard(
|
||||
label = stringResource(fieldLabel(field)),
|
||||
label = stringResource(eventFormFieldLabel(field)),
|
||||
onClick = { onSelect(field) },
|
||||
icon = fieldIcon(field),
|
||||
icon = eventFormFieldIcon(field),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1636,28 +1682,6 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
|
||||
}
|
||||
|
||||
|
||||
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
||||
EventFormField.Location -> R.string.event_detail_location
|
||||
EventFormField.Description -> R.string.event_detail_description
|
||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||
EventFormField.Availability -> R.string.event_edit_availability
|
||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||
EventFormField.Color -> R.string.event_edit_color
|
||||
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||
}
|
||||
|
||||
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||
EventFormField.Location -> Icons.Default.Place
|
||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||
EventFormField.Reminders -> Icons.Default.Notifications
|
||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||
EventFormField.Visibility -> Icons.Default.Lock
|
||||
EventFormField.Color -> Icons.Default.Palette
|
||||
EventFormField.Attendees -> Icons.Default.People
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility selector: one card per level, each with its own icon; the
|
||||
* current level is highlighted. Tap picks and closes.
|
||||
@@ -1725,7 +1749,7 @@ private fun ColorPickerDialog(
|
||||
)
|
||||
} else {
|
||||
ColorSwatchRow(
|
||||
colors = CALENDAR_COLOR_PALETTE,
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = selected,
|
||||
onSelect = onPickRaw,
|
||||
dark = dark,
|
||||
@@ -1860,6 +1884,7 @@ private fun InlineField(
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1873,6 +1898,7 @@ private fun InlineField(
|
||||
textStyle = textStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
enabled = enabled,
|
||||
keyboardType = keyboardType,
|
||||
)
|
||||
}
|
||||
@@ -1887,19 +1913,22 @@ private fun ScheduleRow(
|
||||
onDateClick: () -> Unit,
|
||||
onTimeClick: () -> Unit,
|
||||
isError: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val dateFormat = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
}
|
||||
val timeFormat = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeFormat = remember(locale, use24Hour) {
|
||||
timeOfDayFormatter(use24Hour, locale)
|
||||
}
|
||||
// Tappable values read as links (primary), like the location on the
|
||||
// detail screen; errors flip them to the error colour.
|
||||
val valueColor = if (isError) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
// detail screen; errors flip them to the error colour; a locked (managed)
|
||||
// row dims to a plain, non-tappable value.
|
||||
val valueColor = when {
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
isError -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1916,7 +1945,7 @@ private fun ScheduleRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onDateClick)
|
||||
.clickable(enabled = enabled, onClick = onDateClick)
|
||||
.padding(vertical = 8.dp, horizontal = 6.dp),
|
||||
)
|
||||
if (!isAllDay) {
|
||||
@@ -1925,41 +1954,71 @@ private fun ScheduleRow(
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = valueColor,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onTimeClick)
|
||||
.clickable(enabled = enabled, onClick = onTimeClick)
|
||||
.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
|
||||
private fun CalendarPickerDialog(
|
||||
private fun CalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_detail_calendar)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
calendars.forEach { calendar ->
|
||||
OptionCard(
|
||||
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,
|
||||
)
|
||||
}
|
||||
// Group by owning account (name, else type, else the calendar's own name),
|
||||
// preserving the provider's order within and across groups — the same
|
||||
// grouping [groupByAccount] applies for the drawer filter.
|
||||
val groups = remember(calendars) {
|
||||
calendars
|
||||
.groupBy {
|
||||
it.accountName.takeIf(String::isNotBlank)
|
||||
?: it.accountType.takeIf(String::isNotBlank)
|
||||
?: it.displayName
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
.toList()
|
||||
}
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_calendar),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
groups.forEach { (account, cals) ->
|
||||
Text(
|
||||
text = account,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = positionOf(index, cals.size),
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,18 @@ data class EventEditUiState(
|
||||
val hiddenFields: List<EventFormField> = emptyList(),
|
||||
/** True while editing an existing event (the calendar is then fixed). */
|
||||
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
|
||||
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||
* on it for a new event with an empty title, never when editing/importing.
|
||||
*/
|
||||
val autofocusTitle: Boolean = true,
|
||||
/**
|
||||
* True while an edit changed the recurrence rule — the save-scope dialog
|
||||
* then drops "only this event" (an exception row can't carry a rule).
|
||||
|
||||
@@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val timed: Int?,
|
||||
val allDay: Int?,
|
||||
val timedOverrides: Map<Long, Int?>,
|
||||
val allDayOverrides: Map<Long, Int?>,
|
||||
val timed: List<Int>,
|
||||
val allDay: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
)
|
||||
|
||||
private data class ExternalInputs(
|
||||
@@ -119,20 +119,27 @@ class EventEditViewModel @Inject constructor(
|
||||
val lastUsed: Long?,
|
||||
val defaultFields: Set<EventFormField>,
|
||||
val allowColorOnUnsupported: 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
|
||||
* excluded, so you can't create into a calendar you've removed from the app;
|
||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||
* 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(
|
||||
repository.calendars(),
|
||||
allCalendars,
|
||||
prefs.disabledCalendarIds,
|
||||
) { calendars, disabled ->
|
||||
calendars.filter { it.canModifyContents && it.id !in disabled }
|
||||
}.catch { emit(emptyList()) }
|
||||
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
||||
}
|
||||
|
||||
/** The target calendar id, resolved exactly as the form shows it. */
|
||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||
@@ -160,24 +167,38 @@ class EventEditViewModel @Inject constructor(
|
||||
prefs.lastUsedCalendarId,
|
||||
settingsPrefs.defaultFormFields,
|
||||
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||
settingsPrefs.autofocusEventTitle,
|
||||
::ExternalInputs,
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
) { local, external, palette ->
|
||||
allCalendars,
|
||||
) { local, external, palette, allCalendars ->
|
||||
val form = local.form ?: return@combine null
|
||||
val resolvedId = form.calendarId
|
||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||
?: external.writable.firstOrNull()?.id
|
||||
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
|
||||
EventEditUiState(
|
||||
form = resolved,
|
||||
calendars = external.writable,
|
||||
calendars = pickerCalendars,
|
||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||
saveState = local.saveState,
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
isEditing = local.editTarget != null,
|
||||
isManaged = isManaged,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
// A modified-occurrence exception can't carry its own rule, so
|
||||
// the scope dialog drops "only this event" after a rule change.
|
||||
recurrenceChanged = local.editTarget != null &&
|
||||
@@ -257,7 +278,7 @@ class EventEditViewModel @Inject constructor(
|
||||
// Re-check after suspending: bail if the form closed or the user edited.
|
||||
val form = _form.value ?: return@launch
|
||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||
val default = resolveDefaultReminder(
|
||||
val reminders = resolveDefaultReminder(
|
||||
timedGlobal = defaults.timed,
|
||||
allDayGlobal = defaults.allDay,
|
||||
timedOverrides = defaults.timedOverrides,
|
||||
@@ -265,7 +286,6 @@ class EventEditViewModel @Inject constructor(
|
||||
calendarId = targetId,
|
||||
isAllDay = form.isAllDay,
|
||||
)
|
||||
val reminders = listOfNotNull(default)
|
||||
_form.value = form.copy(reminders = reminders)
|
||||
// Surface the section so an auto-applied default is visible and
|
||||
// removable, even when Reminders isn't a default-shown field.
|
||||
@@ -415,7 +435,10 @@ class EventEditViewModel @Inject constructor(
|
||||
_saveState.value = SaveUiState.Saved
|
||||
return
|
||||
}
|
||||
if (target != null && target.original.rrule != null) {
|
||||
// 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) {
|
||||
_saveState.value = SaveUiState.AwaitingScope
|
||||
return
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -44,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -60,10 +63,15 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
@@ -91,12 +99,22 @@ fun MonthScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
val nowState = rememberCurrentMinute()
|
||||
val dimCutoff by remember(dimCompleted) {
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -142,6 +160,7 @@ fun MonthScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = LocalDate(month.year, month.month, 1),
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -163,7 +182,7 @@ fun MonthScreen(
|
||||
MonthTopBar(
|
||||
month = month,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
@@ -193,14 +212,16 @@ fun MonthScreen(
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
WeekdayHeader(weekStart = weekStart)
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,6 +579,8 @@ private fun MonthBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
@@ -565,7 +588,7 @@ private fun MonthBar(
|
||||
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), shape)
|
||||
.padding(horizontal = 4.dp)
|
||||
.semantics { contentDescription = title },
|
||||
|
||||
@@ -59,6 +59,14 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = DayOfWeek.MONDAY,
|
||||
)
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import java.time.Instant as JavaInstant
|
||||
@@ -217,12 +219,11 @@ private fun searchSummary(event: EventInstance): String {
|
||||
val dateText = remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
}.format(start)
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeText = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
} else {
|
||||
remember(locale) {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
||||
}.format(start)
|
||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||
}
|
||||
val base = "$dateText · $timeText"
|
||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,18 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
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
|
||||
@@ -17,32 +24,52 @@ data class SettingsUiState(
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val dynamicColor: Boolean = true,
|
||||
val dynamicColorAvailable: Boolean = true,
|
||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||
val showHourLines: Boolean = false,
|
||||
/** How the Agenda screen treats events that already ended today. */
|
||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
/** Whether the month/week grids fade events that have already finished. */
|
||||
val dimCompletedEvents: Boolean = false,
|
||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||
/** How far ahead the agenda widget shows events (v2.11). */
|
||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
/** 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"). */
|
||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||
val autofocusEventTitle: Boolean = true,
|
||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||
val remindersEnabled: Boolean = true,
|
||||
/**
|
||||
* The default reminder lead time (minutes) prefilled on new timed events;
|
||||
* null = no default reminder. Per-calendar overrides take precedence.
|
||||
* The default reminder lead times (minutes) prefilled on new timed events;
|
||||
* empty = no default reminder. Per-calendar overrides take precedence.
|
||||
*/
|
||||
val defaultReminderMinutes: Int? = null,
|
||||
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
||||
val defaultAllDayReminderMinutes: Int? = null,
|
||||
val defaultReminderMinutes: List<Int> = emptyList(),
|
||||
/** The default reminder lead times prefilled on new all-day events; empty = none. */
|
||||
val defaultAllDayReminderMinutes: List<Int> = emptyList(),
|
||||
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
|
||||
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
|
||||
/**
|
||||
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||
* calendar present in the map overrides the global default (null value = no
|
||||
* calendar present in the map overrides the global default (empty value = no
|
||||
* reminder); absent = inherit the global default.
|
||||
*/
|
||||
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
||||
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||
/**
|
||||
@@ -50,4 +77,30 @@ data class SettingsUiState(
|
||||
* colour palette (the colour may then not survive their next sync).
|
||||
*/
|
||||
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, CalendarReminderOverride> = 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,18 +1,48 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.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.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
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.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
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.storageValue
|
||||
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_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -20,12 +50,19 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
repository: CalendarRepository,
|
||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
@@ -70,13 +107,45 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.perCalendarReminderOverride,
|
||||
prefs.perCalendarAllDayReminderOverride,
|
||||
writableCalendars,
|
||||
) { overrides, allDayOverrides, calendars ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||
prefs.managedCalendarIds,
|
||||
) { overrides, allDayOverrides, calendars, managedIds ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
||||
},
|
||||
prefs.defaultView,
|
||||
) { base, defaults, overrides, defaultView ->
|
||||
combine(
|
||||
prefs.defaultView,
|
||||
prefs.agendaScreenRange,
|
||||
prefs.agendaWidgetRange,
|
||||
prefs.timeFormat,
|
||||
prefs.showHourLines,
|
||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
||||
},
|
||||
combine(
|
||||
prefs.agendaShowRangeBar,
|
||||
prefs.autofocusEventTitle,
|
||||
prefs.pastEventDisplay,
|
||||
prefs.dimCompletedEvents,
|
||||
// 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.copy(
|
||||
defaultView = defaultView,
|
||||
defaultView = views.defaultView,
|
||||
agendaScreenRange = views.agendaScreenRange,
|
||||
agendaWidgetRange = views.agendaWidgetRange,
|
||||
timeFormat = views.timeFormat,
|
||||
showHourLines = views.showHourLines,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
dimCompletedEvents = misc.dimCompletedEvents,
|
||||
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -85,6 +154,7 @@ class SettingsViewModel @Inject constructor(
|
||||
perCalendarReminderOverride = overrides.timed,
|
||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||
writableCalendars = overrides.calendars,
|
||||
managedCalendarIds = overrides.managedIds,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -92,20 +162,160 @@ class SettingsViewModel @Inject constructor(
|
||||
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
|
||||
// updateAll calls at once — concurrent calls coalesce around a stale read
|
||||
// and can leave the widget on the intermediate value. Held sequentially,
|
||||
// the last call reads the final committed pref and renders it.
|
||||
private val widgetRefreshMutex = Mutex()
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val allowColor: Boolean,
|
||||
val defaultReminder: Int?,
|
||||
val allDayReminder: Int?,
|
||||
val defaultReminder: List<Int>,
|
||||
val allDayReminder: List<Int>,
|
||||
val allDayReminderTime: Int,
|
||||
val snoozeMinutes: Int,
|
||||
)
|
||||
|
||||
private data class ReminderOverrides(
|
||||
val timed: Map<Long, Int?>,
|
||||
val allDay: Map<Long, Int?>,
|
||||
val timed: Map<Long, List<Int>>,
|
||||
val allDay: Map<Long, List<Int>>,
|
||||
val calendars: List<CalendarSource>,
|
||||
val managedIds: Set<Long>,
|
||||
)
|
||||
|
||||
private data class ViewSettings(
|
||||
val defaultView: CalendarView,
|
||||
val agendaScreenRange: AgendaRange,
|
||||
val agendaWidgetRange: AgendaRange,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val showHourLines: Boolean,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
val showRangeBar: Boolean,
|
||||
val autofocusEventTitle: Boolean,
|
||||
val pastEventDisplay: PastEventDisplay,
|
||||
val dimCompletedEvents: Boolean,
|
||||
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.choiceFor(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: CalendarReminderOverride) {
|
||||
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) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
@@ -114,8 +324,98 @@ class SettingsViewModel @Inject constructor(
|
||||
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) {
|
||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||
viewModelScope.launch {
|
||||
prefs.setWeekStart(pref)
|
||||
// Both widgets depend on the week start (month weekday header; the
|
||||
// agenda widget's "this week" range).
|
||||
widgetRefreshMutex.withLock {
|
||||
AgendaWidget().updateAll(appContext)
|
||||
MonthWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaScreenRange(range: AgendaRange) {
|
||||
viewModelScope.launch { prefs.setAgendaScreenRange(range) }
|
||||
}
|
||||
|
||||
fun setAgendaWidgetRange(range: AgendaRange) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaWidgetRange(range)
|
||||
widgetRefreshMutex.withLock {
|
||||
// Push the range into each instance's Glance state and recompose.
|
||||
// The widget reads it via currentState, so this reflects reliably
|
||||
// even when updateAll only recomposes a live session (it does not
|
||||
// re-run provideGlance's data preamble).
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaShowRangeBar(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||
}
|
||||
|
||||
fun setTimeFormat(pref: TimeFormatPref) {
|
||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||
}
|
||||
|
||||
fun setShowHourLines(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||
}
|
||||
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
viewModelScope.launch {
|
||||
prefs.setPastEventDisplay(mode)
|
||||
// The agenda widget honours this setting too, so push it into each
|
||||
// instance's Glance state and recompose — same reliable-update path as
|
||||
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
|
||||
widgetRefreshMutex.withLock {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDimCompletedEvents(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultView(view: CalendarView) {
|
||||
@@ -126,15 +426,45 @@ class SettingsViewModel @Inject constructor(
|
||||
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) {
|
||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||
fun setAutofocusEventTitle(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
||||
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MotionScheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -15,15 +16,18 @@ import androidx.compose.ui.platform.LocalContext
|
||||
* - System light/dark.
|
||||
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
||||
* derived from [CalendulaSeed].
|
||||
* - A user-chosen [typography] (custom fonts, issue #19), defaulting to the
|
||||
* Material scale on the system typeface.
|
||||
*
|
||||
* The Settings screen (later) can override useDynamicColor and themePreference,
|
||||
* but the V1 foundation just follows the system.
|
||||
* The Settings screen overrides darkTheme, dynamicColor, and typography; the
|
||||
* bare defaults (used by the crash screen) just follow the system.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CalendulaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
typography: Typography = CalendulaTypography,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
@@ -42,7 +46,7 @@ fun CalendulaTheme(
|
||||
// the bouncy variant felt overdone in review (2026-06-11).
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = CalendulaTypography,
|
||||
typography = typography,
|
||||
motionScheme = MotionScheme.standard(),
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -45,7 +45,9 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -55,6 +57,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -73,16 +76,26 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
@@ -119,11 +132,21 @@ fun WeekScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
val nowState = rememberCurrentMinute()
|
||||
val dimCutoff by remember(dimCompleted) {
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
@@ -175,6 +198,7 @@ fun WeekScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = weekStart,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -196,7 +220,7 @@ fun WeekScreen(
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
@@ -216,19 +240,21 @@ fun WeekScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -545,8 +571,10 @@ private fun AllDayBar(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
@@ -572,6 +600,8 @@ private fun Timeline(
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||
@@ -595,7 +625,7 @@ private fun Timeline(
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = "%02d".format(h),
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
@@ -650,6 +680,8 @@ private fun DayColumnCard(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -662,6 +694,9 @@ private fun DayColumnCard(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Faint hour separators sit over the column background but under
|
||||
// the event blocks (drawBehind paints before the children).
|
||||
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||
// Tap an empty slot to create an event there; taps on event
|
||||
// blocks are consumed by their own handler first. Snaps to hour.
|
||||
.pointerInput(date) {
|
||||
@@ -680,6 +715,7 @@ private fun DayColumnCard(
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
@@ -700,14 +736,35 @@ private fun DayColumnCard(
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
||||
// On narrow overlapping columns we drop it so the title can wrap to fill the
|
||||
// whole block, mirroring Google Calendar.
|
||||
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
||||
// top/bottom padding and the reserved time line) instead of clipping it to a
|
||||
// single character on slim, overlapping blocks.
|
||||
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
Box(
|
||||
modifier = modifier
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
@@ -717,18 +774,15 @@ private fun EventBlock(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
||||
// wrap to a second line (after the dash) instead of clipping the
|
||||
// end time.
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 2,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
)
|
||||
@@ -773,8 +827,8 @@ private fun WeekLoading() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun minToHm(min: Int): String =
|
||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||
formatMinuteOfDay(min, is24Hour, locale)
|
||||
|
||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
@@ -50,6 +50,14 @@ class WeekViewModel @Inject constructor(
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
private val locale: Locale = Locale.getDefault()
|
||||
|
||||
/** Whether to fade events that have already finished (display concern only). */
|
||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -22,9 +25,7 @@ import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.time.Clock
|
||||
|
||||
/** How far ahead the agenda widget loads (a month of upcoming events). */
|
||||
private const val AGENDA_WIDGET_DAYS = 30
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* How far either side of today the month widget pre-loads. The displayed month
|
||||
@@ -46,7 +47,28 @@ internal fun Context.hasCalendarPermission(): Boolean =
|
||||
sealed interface AgendaWidgetData {
|
||||
/** Calendar permission not granted — the widget can't read events. */
|
||||
data object NeedsPermission : AgendaWidgetData
|
||||
data class Ready(val today: LocalDate, val days: List<AgendaDay>) : AgendaWidgetData
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
/**
|
||||
* Days with events across the **widest** selectable window. The displayed
|
||||
* range is sliced from this in the composition (read reactively from
|
||||
* Glance state), so a range change is pure recomposition rather than a
|
||||
* flaky widget session restart — the same approach the month widget uses.
|
||||
* Kept unfiltered: hiding/dimming finished events also happens reactively
|
||||
* in the composition, against [now], for the same reliable-update reason.
|
||||
*/
|
||||
val days: List<AgendaDay>,
|
||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||
val is24Hour: Boolean,
|
||||
/** First day of the week, for the calendar-aligned "this week" range. */
|
||||
val weekStart: DayOfWeek,
|
||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||
val savedRange: AgendaRange,
|
||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||
val savedPastDisplay: PastEventDisplay,
|
||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||
val now: Instant,
|
||||
) : AgendaWidgetData
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,9 +113,27 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val repo = widgetEntryPoint().calendarRepository()
|
||||
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
||||
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
||||
val ep = widgetEntryPoint()
|
||||
val prefs = ep.settingsPrefs()
|
||||
val savedRange = prefs.agendaWidgetRange.first()
|
||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
// Load the widest selectable window once; the displayed range is sliced in
|
||||
// the composition from Glance state, so changing the range is a plain
|
||||
// recomposition and never depends on the widget session restarting.
|
||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
||||
val instances = ep.calendarRepository().instances(window).first()
|
||||
val is24Hour = prefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||
return AgendaWidgetData.Ready(
|
||||
today = anchor,
|
||||
days = groupAgendaDays(anchor, instances, zone),
|
||||
is24Hour = is24Hour,
|
||||
weekStart = weekStart,
|
||||
savedRange = savedRange,
|
||||
savedPastDisplay = savedPastDisplay,
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
}
|
||||
|
||||
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
@@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
@@ -40,8 +43,16 @@ import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
@@ -62,8 +73,28 @@ import java.util.Locale
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
/**
|
||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||
* The range is read reactively in the composition ([currentState]) so a settings
|
||||
* change is reflected by plain recomposition — `updateAll` does NOT reliably
|
||||
* re-run the `provideGlance` preamble for a live widget session, so loading the
|
||||
* range there and slicing it would silently keep the stale value (mirrors the
|
||||
* month widget's MONTH_INDEX_KEY approach).
|
||||
*/
|
||||
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
||||
|
||||
/**
|
||||
* Per-instance Glance state key holding the past-event display mode (as
|
||||
* [PastEventDisplay.name]). Read reactively in the composition for the same
|
||||
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
|
||||
* widget without depending on the `provideGlance` preamble re-running.
|
||||
*/
|
||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val data = context.loadAgendaWidgetData()
|
||||
val dark = (context.resources.configuration.uiMode and
|
||||
@@ -101,12 +132,36 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||
is AgendaWidgetData.Ready ->
|
||||
if (data.days.isEmpty()) {
|
||||
is AgendaWidgetData.Ready -> {
|
||||
// Range read reactively from per-instance Glance state (falls back
|
||||
// to the saved pref for a freshly placed widget), then the wide
|
||||
// pre-loaded window is sliced to it — pure recomposition.
|
||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||
val pastDisplay =
|
||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||
val rangeEnd = data.today.plus(
|
||||
range.dayCount(data.today, data.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
// Slice to the chosen range, then drop finished events (and any day
|
||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||
val visibleDays = data.days
|
||||
.filter { it.date <= rangeEnd }
|
||||
.let { days ->
|
||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||
days.mapNotNull { day ->
|
||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||
}
|
||||
} else {
|
||||
days
|
||||
}
|
||||
}
|
||||
if (visibleDays.isEmpty()) {
|
||||
WidgetMessage(R.string.agenda_empty_title)
|
||||
} else {
|
||||
val rows = buildList {
|
||||
data.days.forEach { day ->
|
||||
visibleDays.forEach { day ->
|
||||
add(AgendaRow.Header(day.date, data.today))
|
||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||
}
|
||||
@@ -115,11 +170,18 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
items(rows.size) { index ->
|
||||
when (val row = rows[index]) {
|
||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||
is AgendaRow.Event -> EventRow(row.event, dark)
|
||||
is AgendaRow.Event -> EventRow(
|
||||
event = row.event,
|
||||
dark = dark,
|
||||
is24Hour = data.is24Hour,
|
||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||
row.event.hasEnded(data.now),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +193,8 @@ private fun AgendaHeader() {
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
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 = context.getString(R.string.widget_agenda_title),
|
||||
style = TextStyle(
|
||||
@@ -138,7 +202,11 @@ private fun AgendaHeader() {
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_refresh,
|
||||
@@ -195,9 +263,15 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
||||
val context = androidx.glance.LocalContext.current
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||
val stripeColor = pastelize(event.color, dark).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||
Row(
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
@@ -220,17 +294,17 @@ private fun EventRow(event: EventInstance, dark: Boolean) {
|
||||
.width(5.dp)
|
||||
.height(36.dp)
|
||||
.cornerRadius(3.dp)
|
||||
.background(pastelize(event.color, dark)),
|
||||
.background(stripeColor),
|
||||
) {}
|
||||
Spacer(GlanceModifier.width(10.dp))
|
||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
||||
)
|
||||
Text(
|
||||
text = eventTimeSummary(context, event),
|
||||
text = eventTimeSummary(context, event, is24Hour),
|
||||
maxLines = 1,
|
||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||
)
|
||||
@@ -269,17 +343,17 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
||||
return if (relative != null) "$relative · $formatted" else formatted
|
||||
}
|
||||
|
||||
private fun eventTimeSummary(context: Context, event: EventInstance): String {
|
||||
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
|
||||
val time = if (event.isAllDay) {
|
||||
context.getString(R.string.event_detail_all_day)
|
||||
} else {
|
||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
||||
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
||||
}
|
||||
val location = event.location?.takeIf { it.isNotBlank() }
|
||||
return if (location != null) "$time · $location" else time
|
||||
}
|
||||
|
||||
private fun formatTime(instant: Instant): String {
|
||||
private fun formatTime(instant: Instant, is24Hour: Boolean): String {
|
||||
val t = instant.toLocalDateTime(zone()).time
|
||||
return "%02d:%02d".format(t.hour, t.minute)
|
||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
|
||||
}
|
||||
|
||||
@@ -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 = label,
|
||||
style = TextStyle(
|
||||
@@ -204,16 +206,17 @@ private fun MonthHeader(label: String) {
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.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(
|
||||
resId = R.drawable.ic_widget_today,
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
||||
),
|
||||
),
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
)
|
||||
HeaderIcon(
|
||||
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
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
@@ -308,11 +320,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(DAY_NUMBER_HEIGHT)
|
||||
// Tap a day number to open that day, rooted in the month view so back
|
||||
// returns to the month grid.
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
|
||||
),
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
@@ -339,6 +347,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
var col = 0
|
||||
while (col < 7) {
|
||||
@@ -352,7 +361,14 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
if (timed != null) {
|
||||
SpanBar(event = timed, dark = dark, width = colW)
|
||||
} 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
|
||||
}
|
||||
@@ -401,14 +417,20 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||
|
||||
@Composable
|
||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.days.forEachIndexed { col, date ->
|
||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||
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(
|
||||
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
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.
@@ -12,7 +12,7 @@
|
||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<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_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>
|
||||
@@ -215,8 +215,7 @@
|
||||
<string name="agenda_today_action">Heute</string>
|
||||
<string name="agenda_header_today">Heute</string>
|
||||
<string name="agenda_header_tomorrow">Morgen</string>
|
||||
<string name="agenda_empty_title">Nichts geplant</string>
|
||||
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
|
||||
<string name="agenda_empty_title">Alles erledigt</string>
|
||||
<!-- Terminsuche -->
|
||||
<string name="search_action">Suchen</string>
|
||||
<string name="search_hint">Termine suchen</string>
|
||||
@@ -255,10 +254,35 @@
|
||||
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
||||
<string name="settings_week_start">Wochenstart</string>
|
||||
<string name="settings_week_start_auto">Automatisch</string>
|
||||
<string name="settings_week_start_monday">Montag</string>
|
||||
<string name="settings_week_start_sunday">Sonntag</string>
|
||||
<string name="settings_time_format">Zeitformat</string>
|
||||
<string name="settings_time_format_auto">Automatisch</string>
|
||||
<string name="settings_time_format_12h">12-Stunden (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
|
||||
<string name="settings_hour_lines">Stundenlinien</string>
|
||||
<string name="settings_hour_lines_summary">Trennlinie zu jeder vollen Stunde in Wochen- und Tagesansicht anzeigen</string>
|
||||
<string name="settings_agenda_range">Agenda-Zeitraum</string>
|
||||
<string name="settings_agenda_range_hint">Wie weit im Voraus die Agenda-Ansicht Termine anzeigt.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string>
|
||||
<string name="settings_agenda_widget_range_hint">Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt.</string>
|
||||
<string name="settings_agenda_range_bar">Zeitraum-Leiste</string>
|
||||
<string name="settings_agenda_range_bar_hint">Eine Leiste oben in der Agenda anzeigen, die den dargestellten Zeitraum benennt, mit einer Schaltfläche zum Wechseln des Zeitraums für die Sitzung</string>
|
||||
<string name="agenda_range_day">Heute</string>
|
||||
<string name="agenda_range_this_week">Diese Woche</string>
|
||||
<string name="agenda_range_this_month">Dieser Monat</string>
|
||||
<string name="agenda_range_week">Nächste 7 Tage</string>
|
||||
<string name="agenda_range_month">Nächste 30 Tage</string>
|
||||
<string name="agenda_range_custom">Benutzerdefiniert…</string>
|
||||
<string name="agenda_range_custom_hint">Tage</string>
|
||||
<string name="agenda_range_override_hint">Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt.</string>
|
||||
<string name="agenda_range_showing_label">Alle anstehenden Termine für</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d Tag</item>
|
||||
<item quantity="other">%d Tage</item>
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">Termin-Formular</string>
|
||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||
<string name="settings_autofocus_title">Titel bei neuem Termin fokussieren</string>
|
||||
<string name="settings_autofocus_title_hint">Beim Anlegen eines neuen Termins den Cursor direkt ins Titelfeld setzen und die Tastatur öffnen.</string>
|
||||
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
|
||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||
@@ -271,7 +295,6 @@
|
||||
<string name="reminder_none">Keine</string>
|
||||
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
|
||||
<string name="reminder_custom_amount">Anzahl</string>
|
||||
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
|
||||
<string name="reminder_custom_set">Übernehmen</string>
|
||||
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
|
||||
@@ -286,7 +309,7 @@
|
||||
<string name="settings_language">App-Sprache</string>
|
||||
<string name="settings_language_auto">Systemstandard</string>
|
||||
<!-- 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_notifications_subtitle">Termin-Erinnerungen</string>
|
||||
<string name="settings_section_about">Über</string>
|
||||
@@ -364,4 +387,27 @@
|
||||
<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_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">Woche</item>
|
||||
<item quantity="other">Wochen</item>
|
||||
</plurals>
|
||||
<string name="settings_default_view">Standartansicht</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>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,469 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</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>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,459 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</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">Caricamento…</string>
|
||||
<string name="state_retry">Riprova</string>
|
||||
<string name="state_failure_unknown">Qualcosa è andato storto.</string>
|
||||
<string name="state_failure_permission">È richiesto l\'accesso al calendario.</string>
|
||||
<string name="state_failure_permission_action">Concedi l\'accesso</string>
|
||||
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
|
||||
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
|
||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
|
||||
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
||||
<string name="permission_retry_button">Riprova</string>
|
||||
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
||||
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
|
||||
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
|
||||
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
||||
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
|
||||
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
|
||||
<string name="month_prev">Mese precedente</string>
|
||||
<string name="month_next">Mese prossimo</string>
|
||||
<string name="month_today_action">Oggi</string>
|
||||
<string name="month_more_actions">Altre azioni</string>
|
||||
<string name="month_open_menu">Apri il menù</string>
|
||||
<string name="month_action_settings">Impostazioni</string>
|
||||
<string name="month_a11y_today_prefix">Oggi</string>
|
||||
<string name="week_today_action">Questa settimana</string>
|
||||
<string name="week_number_label">Wk</string>
|
||||
<string name="day_today_action">Oggi</string>
|
||||
<string name="event_detail_back">Indietro</string>
|
||||
<string name="event_detail_edit">Modifica</string>
|
||||
<string name="event_detail_delete">Elimina</string>
|
||||
<string name="event_detail_share">Condividi</string>
|
||||
<string name="event_share_chooser_title">Condividi evento</string>
|
||||
<string name="event_share_failed">Impossibile condividere questo evento.</string>
|
||||
<string name="event_delete_title">Eliminare l\'evento?</string>
|
||||
<string name="event_delete_body">Questo evento viene rimosso dal tuo calendario e da tutti i dispositivi sincronizzati.</string>
|
||||
<string name="event_delete_recurring_title">Elimina ricorrenza</string>
|
||||
<string name="event_delete_option_occurrence">Solo questo evento</string>
|
||||
<string name="event_delete_option_following">Questo e gli eventi successivi</string>
|
||||
<string name="event_delete_option_series">Tutti gli eventi della serie</string>
|
||||
<string name="event_edit_recurring_title">Modifica ricorrenza</string>
|
||||
<string name="event_delete_failed">Impossibile eliminare l\'evento</string>
|
||||
<string name="event_delete_write_denied">Calendula ha bisogno dei permessi di scrittura per eliminare gli eventi</string>
|
||||
<string name="dialog_cancel">Annulla</string>
|
||||
<string name="dialog_ok">Ok</string>
|
||||
<string name="event_edit_new_title">Nuovo evento</string>
|
||||
<string name="event_edit_close">Chiudi</string>
|
||||
<string name="event_edit_save">Salva</string>
|
||||
<string name="event_edit_title_hint">Aggiungi titolo</string>
|
||||
<string name="event_edit_starts">Inizio</string>
|
||||
<string name="event_edit_ends">Fine</string>
|
||||
<string name="event_edit_error_end_before_start">Fine antecedente l\'inizio</string>
|
||||
<string name="event_edit_error_no_calendar">Non sono disponibili calendari</string>
|
||||
<string name="event_edit_save_failed">Impossibile salvare l\'evento</string>
|
||||
<string name="event_edit_write_denied">Calendula ha bisogno dei permessi di scrittura per creare gli eventi</string>
|
||||
<string name="event_edit_more_fields">Altri campi</string>
|
||||
<string name="event_edit_add">Aggiungi</string>
|
||||
<string name="event_edit_add_reminder">Aggiungi promemoria</string>
|
||||
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
||||
<string name="event_edit_attendees">Partecipanti</string>
|
||||
<string name="event_edit_add_guest">Aggiungi partecipante</string>
|
||||
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
|
||||
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
|
||||
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
||||
<string name="event_edit_attendee_required">Obbligatorio</string>
|
||||
<string name="event_edit_attendee_optional">Facoltativo</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
|
||||
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
|
||||
<string name="event_edit_reminder_custom">Personalizza</string>
|
||||
<string name="reminder_unit_minutes">minuti</string>
|
||||
<string name="reminder_unit_hours">ore</string>
|
||||
<string name="reminder_unit_days">giorni</string>
|
||||
<string name="reminder_unit_weeks">settimane</string>
|
||||
<string name="event_edit_availability">Disponibilità</string>
|
||||
<string name="event_edit_visibility">Visibilità</string>
|
||||
<string name="event_edit_color">Colore</string>
|
||||
<string name="event_edit_color_default">Colore calendario</string>
|
||||
<string name="event_edit_color_custom">Colore personalizzato</string>
|
||||
<string name="event_edit_color_reset">Reset</string>
|
||||
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
|
||||
<string name="event_edit_color_unsupported_hint">I colori non sono ammessi per questo calendario. Puoi abilitare i colori personalizzati per questo tipo di calendario dalle Impostazioni.</string>
|
||||
<string name="event_edit_color_sync_warning">Il colore per questo calendario potrà andare perso o essere sovrascritto alla prossima sincronizzazione.</string>
|
||||
<string name="event_edit_conflict_title">Evento modificato altrove</string>
|
||||
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
|
||||
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
|
||||
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
|
||||
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
|
||||
<string name="event_edit_gone_title">Evento eliminato</string>
|
||||
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
|
||||
<string name="event_edit_recurrence_none">Non si ripete</string>
|
||||
<string name="event_edit_recurrence_custom">Personalizza</string>
|
||||
<string name="event_edit_recurrence_every">La serie</string>
|
||||
<string name="recurrence_unit_days">giorni</string>
|
||||
<string name="recurrence_unit_weeks">settimane</string>
|
||||
<string name="recurrence_unit_months">mesi</string>
|
||||
<string name="recurrence_unit_years">anni</string>
|
||||
<string name="event_edit_recurrence_ends">Fine</string>
|
||||
<string name="event_edit_recurrence_end_never">Mai</string>
|
||||
<string name="event_edit_recurrence_end_until">Fino alla data</string>
|
||||
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
|
||||
<string name="event_edit_recurrence_times">occorrenze</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
|
||||
<string name="event_availability_busy">Occupato</string>
|
||||
<string name="event_access_default">Default</string>
|
||||
<string name="event_access_public">Pubblico</string>
|
||||
<string name="event_detail_all_day">Tutto il giorno</string>
|
||||
<string name="event_detail_calendar">Calendario</string>
|
||||
<string name="event_detail_calendar_unknown">Calendario sconosciuto</string>
|
||||
<string name="event_detail_location">Luogo</string>
|
||||
<string name="event_detail_description">Descrizione</string>
|
||||
<string name="event_detail_attendees">Partecipanti</string>
|
||||
<string name="recurrence_daily">Ogni giorni</string>
|
||||
<string name="recurrence_weekly">Ogni settimana</string>
|
||||
<string name="recurrence_monthly">Ogni mese</string>
|
||||
<string name="recurrence_yearly">Ogni anno</string>
|
||||
<string name="recurrence_every_n_days">Ogni %1$d giorni</string>
|
||||
<string name="recurrence_every_n_weeks">Ogni %1$d settimane</string>
|
||||
<string name="recurrence_every_n_months">Ogni %1$d mesi</string>
|
||||
<string name="recurrence_every_n_years">Ogni %1$d anni</string>
|
||||
<string name="event_detail_not_found">Questo evento è stato eliminato.</string>
|
||||
<string name="event_attendee_accepted">Accettato</string>
|
||||
<string name="event_attendee_declined">Rifiutato</string>
|
||||
<string name="event_attendee_tentative">Provvisorio</string>
|
||||
<string name="event_attendee_needs_action">Nessuna risposta</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_detail_reminders">Promemoria</string>
|
||||
<string name="event_detail_timezone">Fuso orario</string>
|
||||
<string name="event_status_tentative">Provvisorio</string>
|
||||
<string name="event_status_cancelled">Annullato</string>
|
||||
<string name="event_availability_free">Disponibile</string>
|
||||
<string name="event_access_private">Privato</string>
|
||||
<string name="event_access_confidential">Confidenziale</string>
|
||||
<string name="event_attendee_organizer">Organizzatore</string>
|
||||
<string name="event_attendee_optional">Facoltativo</string>
|
||||
<string name="event_detail_self_response">La tua risposta: %1$s</string>
|
||||
<string name="reminder_at_time">All\'ora dell\'evento</string>
|
||||
<string name="reminder_default">Promemoria standard</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minuto prima</item>
|
||||
<item quantity="many">%d minuti prima</item>
|
||||
<item quantity="other">%d minuti prima</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">%d ora prima</item>
|
||||
<item quantity="many">%d ore prima</item>
|
||||
<item quantity="other">%d ore prima</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="one">%d giorno prima</item>
|
||||
<item quantity="many">%d giorni prima</item>
|
||||
<item quantity="other">%d giorni prima</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="one">%d settimana prima</item>
|
||||
<item quantity="many">%d settimanw prima</item>
|
||||
<item quantity="other">%d settimane prima</item>
|
||||
</plurals>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d minuto</item>
|
||||
<item quantity="many">%d minuti</item>
|
||||
<item quantity="other">%d minuti</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d ora</item>
|
||||
<item quantity="many">%d ore</item>
|
||||
<item quantity="other">%d ore</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d giorno</item>
|
||||
<item quantity="many">%d giorni</item>
|
||||
<item quantity="other">%d giorni</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d settimana</item>
|
||||
<item quantity="many">%d settimane</item>
|
||||
<item quantity="other">%d settimane</item>
|
||||
</plurals>
|
||||
<string name="event_untitled">(nessun titolo)</string>
|
||||
<string name="reminder_channel_name">Promemoria evento</string>
|
||||
<string name="event_attendee_resource">Risorsa</string>
|
||||
<string name="reminder_onboarding_title">Non perderti nessun evento</string>
|
||||
<string name="reminder_onboarding_body">Android non mostra i promemoria per gli eventi autonomamente, serve un\'app. Lascia che sia Calendula a farlo.</string>
|
||||
<string name="reminder_benefit_delivery_title">Promemoria, eccoli</string>
|
||||
<string name="reminder_benefit_delivery_body">Tutti i promemoria dei tuoi eventi arrivano tramite notifica, proprio al momento giusto.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Stai usando un\'altra app calendario?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Se un\'altra app manda promemoria li vedrai due volte. Disattivali qui o nell\'altra app.</string>
|
||||
<string name="reminder_benefit_reversible_title">Cambialo in ogni momento</string>
|
||||
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
|
||||
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
|
||||
<string name="reminder_onboarding_skip_button">Non ora</string>
|
||||
<string name="reminder_action_snooze">Posponi</string>
|
||||
<string name="reminder_action_dismiss">Ignora</string>
|
||||
<string name="view_month">Mese</string>
|
||||
<string name="view_week">Settimana</string>
|
||||
<string name="view_day">Giorno</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Vista</string>
|
||||
<string name="drawer_jump_to_date">Vai alla data</string>
|
||||
<string name="agenda_today_action">Oggi</string>
|
||||
<string name="agenda_header_tomorrow">Domani</string>
|
||||
<string name="agenda_empty_title">Hai fatto tutto</string>
|
||||
<string name="search_action">Cerca</string>
|
||||
<string name="search_hint">Cerca eventi</string>
|
||||
<string name="search_back">Indietro</string>
|
||||
<string name="search_clear">Pulisci</string>
|
||||
<string name="search_idle_hint">Cerca gli eventi per titolo, luogo o note.</string>
|
||||
<string name="search_empty">Nessun risultato per \"%1$s\".</string>
|
||||
<string name="widget_agenda_title">Prossimamente</string>
|
||||
<string name="widget_agenda_label">Agenda Calendula</string>
|
||||
<string name="widget_month_label">Mese Calendula</string>
|
||||
<string name="widget_refresh">Aggiorna</string>
|
||||
<string name="widget_new_event">Nuovo evento</string>
|
||||
<string name="widget_needs_permission">Apri Calendula per concedere l\'accesso al calendario</string>
|
||||
<string name="widget_prev_month">Mese precedente</string>
|
||||
<string name="filter_title">Calendari</string>
|
||||
<string name="settings_section_appearance">Aspetto</string>
|
||||
<string name="settings_theme">Tema</string>
|
||||
<string name="settings_theme_system">Sistema</string>
|
||||
<string name="settings_theme_light">Chiaro</string>
|
||||
<string name="settings_theme_dark">Scuro</string>
|
||||
<string name="settings_default_view">Vista di default</string>
|
||||
<string name="settings_dynamic_color">Colori dinamici</string>
|
||||
<string name="settings_dynamic_color_unavailable">Richiede Android 12 o superiore</string>
|
||||
<string name="settings_week_start">La settimana comincia di</string>
|
||||
<string name="settings_week_start_auto">Automatico</string>
|
||||
<string name="settings_time_format">Formato ora</string>
|
||||
<string name="settings_time_format_auto">Automatico</string>
|
||||
<string name="settings_time_format_12h">12 ore</string>
|
||||
<string name="settings_time_format_24h">24 ore</string>
|
||||
<string name="settings_hour_lines">Righe per le ore</string>
|
||||
<string name="settings_hour_lines_summary">Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno</string>
|
||||
<string name="settings_dim_completed">Attenua gli eventi completati</string>
|
||||
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
|
||||
<string name="settings_past_events">Eventi passati</string>
|
||||
<string name="settings_past_events_show">Mostra</string>
|
||||
<string name="settings_past_events_dim">Attenua</string>
|
||||
<string name="settings_past_events_hide">Nascondi</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Intervallo dell\'Agenda</string>
|
||||
<string name="settings_agenda_range_hint">Anticipo con cui vengono visualizzati gli eventi nell\'Agenda.</string>
|
||||
<string name="settings_agenda_widget_range">Intervallo del widget Agenda</string>
|
||||
<string name="settings_agenda_widget_range_hint">Anticipo con cui vengono visualizzati gli eventi nel widget Agenda.</string>
|
||||
<string name="settings_agenda_range_bar">Barra dell\'intervallo</string>
|
||||
<string name="settings_agenda_range_bar_hint">Mostra una barra sopra l\'agenda che indica le date mostrate, con un pulsante per cambiare l\'intervallo scelto per la sessione</string>
|
||||
<string name="agenda_range_day">Oggi</string>
|
||||
<string name="agenda_range_this_month">Questo mese</string>
|
||||
<string name="agenda_range_week">Prossimi 7 giorni</string>
|
||||
<string name="agenda_range_month">Prossimi 30 giorni</string>
|
||||
<string name="agenda_range_custom">Personalizza…</string>
|
||||
<string name="agenda_range_custom_hint">Giorni</string>
|
||||
<string name="agenda_range_override_hint">Solo questa volta, ripristina l\'intervallo salvato al riavvio di Calendula.</string>
|
||||
<string name="agenda_range_showing_label">Mostra tutti gli eventi in arrivo per</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d giorno</item>
|
||||
<item quantity="many">%d giorni</item>
|
||||
<item quantity="other">%d giorni</item>
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">Scheda Nuovo Evento</string>
|
||||
<string name="settings_form_fields_hint">Campi mostrati di default, tutti gli altri saranno in \"Altri campi\"</string>
|
||||
<string name="settings_autofocus_title">Subito al titolo su un nuovo evento</string>
|
||||
<string name="settings_autofocus_title_hint">Quando crei un nuovo evento, posiziona subito il cursore sul campo Titolo e mostra la tastiera.</string>
|
||||
<string name="settings_color_unsupported">Abilita i colori sui calendari non supportati</string>
|
||||
<string name="settings_color_unsupported_hint">Alcuni calendari (e.g. alcuni CalDAV) non pubblicano il set di colori di cui sono dotati; i colori personalizzati assegnati a un evento possono essere persi o sovrascritti alla prossima sincronizzazione. È una limitazione di questo tipo di calendari, Calendula non può farci nulla.</string>
|
||||
<string name="settings_section_notifications">Notifiche</string>
|
||||
<string name="settings_reminders_hint">Vedi promemoria duplicati? Vuol dire che li sta mostrando anche un\'altra app. Disattivali qui o nell\'altra app.</string>
|
||||
<string name="settings_default_reminder_allday">Eventi giornalieri</string>
|
||||
<string name="settings_allday_reminder_time">Ora per i promemoria degli eventi giornalieri</string>
|
||||
<string name="settings_allday_reminder_time_hint">I promemoria per gli eventi giornalieri vengono mandati alle %1$s</string>
|
||||
<string name="reminder_none">Nessuno</string>
|
||||
<string name="reminder_use_default">Usa il promemoria standard</string>
|
||||
<string name="reminder_custom_amount">Valore</string>
|
||||
<string name="reminder_custom_set">Imposta</string>
|
||||
<string name="settings_calendar_reminders_title">Promemoria per calendario</string>
|
||||
<string name="settings_calendar_reminders_hint">Sovrascrivi i reminder standard con reminder specifici per calendario, separati per eventi giornalieri ed eventi ad ore. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Notifiche affidabili</string>
|
||||
<string name="settings_reliable_delivery_hint">Android potrebbe posticipare le notifiche per risparmiare batteria. Escludi Calendula così che le notifiche possano arrivare puntuali.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Escludi dall\'ottimizzazione batteria. I promemoria arriveranno puntuali.</string>
|
||||
<string name="settings_snooze_duration">Durata del posticipo</string>
|
||||
<string name="settings_section_calendars">Calendari</string>
|
||||
<string name="settings_manage_calendars">Gestisci calendari</string>
|
||||
<string name="settings_manage_calendars_hint">Crea calendari locali; gestisci quelli sincronizzati</string>
|
||||
<string name="settings_section_language">Lingua</string>
|
||||
<string name="settings_language">Lingua dell\'app</string>
|
||||
<string name="settings_language_auto">Lingua di sistema</string>
|
||||
<string name="settings_translate">Aiuta a tradurre</string>
|
||||
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
|
||||
<string name="settings_appearance_subtitle">Tema, vista di default, inizio settimana</string>
|
||||
<string name="settings_event_form_subtitle">Campi di default per nuovi eventi</string>
|
||||
<string name="settings_section_about">A proposito</string>
|
||||
<string name="settings_license">Licenza</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">da Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Sorgente</string>
|
||||
<string name="settings_about_support">Sostieni lo sviluppo</string>
|
||||
<string name="settings_about_version">Versione %1$s</string>
|
||||
<string name="settings_about_logo_desc">Icona dell\'app</string>
|
||||
<string name="settings_report_problem">Segnala un problema</string>
|
||||
<string name="settings_report_problem_hint">Invia un crash report o apri il gestore dei problemi</string>
|
||||
<string name="calendars_title">Calendari</string>
|
||||
<string name="calendars_local_header">I tuoi calendari</string>
|
||||
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno per avere gli eventi solo su questo dispositivo.</string>
|
||||
<string name="calendars_add">Aggiungi calendario</string>
|
||||
<string name="calendars_disable_hint">.Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
|
||||
<string name="calendars_show_in_app_a11y">Mostra \"%1$s\" nell\'app</string>
|
||||
<string name="calendars_synced_header">Calendari sincronizzati</string>
|
||||
<string name="calendars_synced_hint">Questi vengono dagli account presenti sul tuo dispositivo. Creali e modificali nella loro app.</string>
|
||||
<string name="calendars_manage_in_app">Gestisci in app</string>
|
||||
<string name="calendars_account_menu_a11y">Altre opzioni per %1$s</string>
|
||||
<string name="calendars_enable_all">Attiva tutti</string>
|
||||
<string name="calendars_disable_all">Disattiva tutti</string>
|
||||
<string name="calendars_add_account">Aggiungi account</string>
|
||||
<string name="calendars_new_title">Nuovo calendario</string>
|
||||
<string name="calendars_edit_title">Modifica calendario</string>
|
||||
<string name="calendars_name_label">Nome</string>
|
||||
<string name="calendars_color_label">Colore</string>
|
||||
<string name="calendars_description_hint">Aggiungi descrizione</string>
|
||||
<string name="calendars_delete_confirm_title">Eliminare calendario?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" e tutti i suoi eventi saranno definitivamente rimossi da questo dispositivo.</string>
|
||||
<string name="calendars_write_error">Impossibile salvare le modifiche.</string>
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">I calendari locali non vengono sincronizzati da nessuna parte, esportali in un file .ics per conservarne una copia.</string>
|
||||
<string name="calendars_backup_action">Esporta come file .ics</string>
|
||||
<string name="calendars_auto_backup">Backup automatico</string>
|
||||
<string name="calendars_auto_backup_hint">Esporta periodicamente in una cartella i tuoi calendari in formato .ics.</string>
|
||||
<string name="calendars_auto_backup_folder">Cartella di backup</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Scegli una cartella</string>
|
||||
<string name="calendars_auto_backup_interval">Intervallo</string>
|
||||
<string name="calendars_auto_backup_every">Ogni %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimo 30 minuti.</string>
|
||||
<string name="calendars_auto_backup_status_never">Backup automatico non attivo</string>
|
||||
<string name="calendars_auto_backup_status_ok">Ultimo backup: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Ultimo backup fallito: %1$s</string>
|
||||
<string name="backup_channel_name">Backup</string>
|
||||
<string name="backup_channel_description">Avvisa se i backup automatici falliscono ripetutamente.</string>
|
||||
<string name="backup_failed_title">Backup automatico fallito</string>
|
||||
<string name="backup_failed_text">Calendula non è riuscito a esportare il file di backup. Controlla la cartella di backup nelle Impostazioni.</string>
|
||||
<string name="calendars_backup_failed">Impossibile esportare il backup.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Esportato %d evento.</item>
|
||||
<item quantity="many">Esportati %d eventi.</item>
|
||||
<item quantity="other">Esportati %d eventi.</item>
|
||||
</plurals>
|
||||
<string name="import_title">Importa eventi</string>
|
||||
<string name="import_target_header">Aggiungi al calendario</string>
|
||||
<string name="import_empty">Non ci sono eventi in questo file.</string>
|
||||
<string name="import_failed">Impossibile leggere il file.</string>
|
||||
<string name="import_no_calendar">Non ci sono calendari su cui importare. Crea prima un calendario locale.</string>
|
||||
<string name="import_done_title">Import completato</string>
|
||||
<string name="import_close">Chiudi</string>
|
||||
<string name="import_warning_recurrence">Alcune occorrenze degli eventi ricorrenti, che erano state modificate, sono state saltate.</string>
|
||||
<string name="import_warning_no_start">Un evento senza orario di inizio è stato saltato.</string>
|
||||
<string name="import_warning_attendees">Gli elenchi dei partecipanti non sono stati importati.</string>
|
||||
<string name="import_warning_timezone">Un fuso orario sconosciuto è tornato sul tuo dispositivo.</string>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">%d evento in questo file.</item>
|
||||
<item quantity="many">%d eventi in questo file.</item>
|
||||
<item quantity="other">%d eventi in questo file.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="one">Importa %d evento</item>
|
||||
<item quantity="many">Importa %d eventi</item>
|
||||
<item quantity="other">Importa %d eventi</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="one">Importato %d evento.</item>
|
||||
<item quantity="many">Importati %d eventi.</item>
|
||||
<item quantity="other">Importati %d eventi.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="one">Saltato %d evento già presente nel calendario.</item>
|
||||
<item quantity="many">SaltatI %d eventi già presentI nel calendario.</item>
|
||||
<item quantity="other">SaltatI %d eventi già presentI nel calendario.</item>
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_long">Crea un nuovo evento</string>
|
||||
<string name="qs_tile_new_event_label">Nuovo evento</string>
|
||||
<string name="settings_qs_tile">Aggiungi un toggle nelle Impostazioni Rapide</string>
|
||||
<string name="settings_qs_tile_hint">Aggiungi un toggle \"Nuovo evento\" nel pannello delle Impostazioni Rapide.</string>
|
||||
<string name="crash_dialog_title">Calendula è crashato</string>
|
||||
<string name="crash_dialog_message">Calendula si è chiuso inaspettatamente l\'ultima volta. Puoi contribuire allo sviluppo inviando questo report, che rimane sul tuo dispositivo finchè non scegli di inviarlo e non include informazioni personali o sui tuoi calendari:solo le informazioni tecniche qui sotto.</string>
|
||||
<string name="crash_dialog_report">Report</string>
|
||||
<string name="crash_dialog_dismiss">Non ora</string>
|
||||
<string name="crash_report_issue_title">Crash report</string>
|
||||
<string name="crash_report_clip_label">Calendula crash report</string>
|
||||
<string name="crash_report_copied">Report copiato negli Appunti</string>
|
||||
<string name="crash_report_open_failed">Impossibile aprire il gestore dei problemi. Il report è sugli Appunti.</string>
|
||||
<string name="crash_report_body_template">Grazie per riportare un crash di Calendula. Per favore, aggiungi ogni informazione che ricordi in merito a quello che stavi facendo, quindi invia\n\n### Cosa è successo\n\n\n###Crash report\n%1$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Il report era troppo lungo per questo link - copialo qui dai tuoi Appunti.)_</string>
|
||||
<string name="event_detail_recurrence">Ricorrenza</string>
|
||||
<string name="event_detail_recurring">Evento ricorrente</string>
|
||||
<string name="recurrence_on_days">%1$s da %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s fino a %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d volte</string>
|
||||
<string name="reminder_channel_description">Notifiche al momento del promemoria dei tuoi eventi</string>
|
||||
<string name="agenda_header_today">Oggi</string>
|
||||
<string name="widget_next_month">Mese prossimo</string>
|
||||
<string name="widget_today">Oggi</string>
|
||||
<string name="settings_title">Impostazioni</string>
|
||||
<string name="settings_back">Indietro</string>
|
||||
<string name="agenda_range_this_week">Questa settimana</string>
|
||||
<string name="settings_reminders">Promemoria evento</string>
|
||||
<string name="settings_default_reminder">Promemoria standard</string>
|
||||
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
||||
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||
<string name="event_edit_managed_hint">Gestito da “%1$s” - titolo, data e ripetizioni sono sincronizzate dai tuoi contatti. Promemoria e note sono modificabili dall\'utente</string>
|
||||
<string name="settings_font_headings">Carattere dell\'intestazione</string>
|
||||
<string name="settings_font_body">Carattere del corpo</string>
|
||||
<string name="settings_font_system">Predefinito di sistema</string>
|
||||
<string name="settings_font_choose_file">Scegli file…</string>
|
||||
<string name="settings_font_custom_selected">Carattere personalizzato</string>
|
||||
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
||||
<string name="settings_section_views">Viste</string>
|
||||
<string name="settings_quick_switch_header">Pulsante di cambio rapido</string>
|
||||
<string name="settings_quick_switch_hint">Scegli attraverso quali viste scorrere mediante il pulsante in alto a destra, trascinale per riordinarle. Le viste disattivate rimangono raggiungibili dal menù di navigazione.</string>
|
||||
<string name="settings_drawer_order_header">Menù di navigazione</string>
|
||||
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel menù di navigazione.</string>
|
||||
<string name="reorder_drag_handle">Trascina per riordinare</string>
|
||||
<string name="settings_views_subtitle">Ordine del pulsante di cambio rapido e del menù</string>
|
||||
<string name="settings_special_dates_subtitle">Compleanni e anniversari dei contatti</string>
|
||||
<string name="settings_section_special_dates">Date speciali dei contatti</string>
|
||||
<string name="settings_special_dates_enable">Mostra date dei contatti</string>
|
||||
<string name="settings_special_dates_enable_hint">Riporta compleanni ed altre date dei tuoi contatti nei calendari locali. Legge i contatti solo su questo dispositivo - niente viene caricato in rete ed i tuoi contatti non vengono mai modificati.</string>
|
||||
<string name="settings_special_dates_type_birthday">Compleanni</string>
|
||||
<string name="settings_special_dates_type_anniversary">Anniversari</string>
|
||||
<string name="settings_special_dates_type_custom">Altre date</string>
|
||||
<string name="settings_special_dates_template">Formato del titolo</string>
|
||||
<string name="settings_special_dates_template_hint">Usa {name} per il contatto e {year} per l\'anno (l\'anno di nascita, o l\'anno di inizio di un anniversario; nascosto se sconosciuto).</string>
|
||||
<string name="settings_special_dates_reminders">Promemoria</string>
|
||||
<string name="settings_special_dates_show_year">Mostra anno</string>
|
||||
<string name="settings_special_dates_show_year_hint">Include {year} nei titoli quando è noto</string>
|
||||
<string name="settings_special_dates_sync_now">Sincronizza adesso</string>
|
||||
<string name="settings_special_dates_never_synced">Ancora non sincronizzato</string>
|
||||
<string name="settings_special_dates_last_synced">%1$s è un tempo relativo, per esempio \"5 minuti fa\"</string>
|
||||
<string name="settings_special_dates_calendar_hint">Imposta colore e visibilità di ogni calendario nelle impostazioni dei calendari.</string>
|
||||
<string name="settings_special_dates_paused_title">In pausa</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula non può più leggere i tuoi contatti, quindi questi calendari non vengono aggiornati.</string>
|
||||
<string name="settings_special_dates_grant">Concedi accesso</string>
|
||||
<string name="settings_special_dates_disable_title">Disattivare date dei contatti?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Questo cancella i calendari delle date dei contatti ed i loro eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Questo cancella il calendario “%1$s” ed i suoi eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Spegni</string>
|
||||
<string name="dialog_save">Salva</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
||||
122
app/src/main/res/values-zh-rCN/strings.xml
Normal file
122
app/src/main/res/values-zh-rCN/strings.xml
Normal file
@@ -0,0 +1,122 @@
|
||||
<?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">一个现代化的日历应用</string>
|
||||
<string name="state_loading">加载中…</string>
|
||||
<string name="state_retry">推迟</string>
|
||||
<string name="state_failure_unknown">错误!</string>
|
||||
<string name="state_failure_permission">需要日历权限</string>
|
||||
<string name="state_failure_permission_action">授予访问权限</string>
|
||||
<string name="state_failure_no_calendars">无已设置的日历</string>
|
||||
<string name="state_failure_no_calendars_action">打开系统日历设置</string>
|
||||
<string name="state_failure_provider">无法读取日历</string>
|
||||
<string name="permission_rationale_title">优雅地查看你的所有活动</string>
|
||||
<string name="permission_rationale_body">Calendula需要获取权限来访问您的日历来展示和管理您的活动。所有权限均会在使用前申请——不必担心隐私泄露。</string>
|
||||
<string name="permission_request_button">授予日历权限</string>
|
||||
<string name="permission_denied_title">日历权限不可用</string>
|
||||
<string name="permission_denied_body">Calendula 无法显示没有日历访问权限的事件。你可以在系统设置里再次授予它。</string>
|
||||
<string name="permission_open_settings_button">打开系统设置</string>
|
||||
<string name="permission_retry_button">重试</string>
|
||||
<string name="permission_benefit_private_title">保留在设备</string>
|
||||
<string name="permission_benefit_private_body">您的日历数据将会在本地读取,而不会被上传。</string>
|
||||
<string name="permission_benefit_sync_title">所有日历</string>
|
||||
<string name="permission_benefit_sync_body">Google、CalDAV、本地设备——任何同步到设备的东西都会直接显示出来。</string>
|
||||
<string name="permission_benefit_privacy_title">无追踪器</string>
|
||||
<string name="permission_benefit_privacy_body">零遥测,零分析,没有广告。</string>
|
||||
<string name="permission_privacy_footnote">数据仅保留在本地设备 ·无网络权限</string>
|
||||
<string name="month_prev">上个月</string>
|
||||
<string name="month_next">下个月</string>
|
||||
<string name="month_today_action">今天</string>
|
||||
<string name="month_more_actions">更多选项</string>
|
||||
<string name="month_open_menu">打开菜单</string>
|
||||
<string name="month_action_settings">设置</string>
|
||||
<string name="month_a11y_today_prefix">今天</string>
|
||||
<string name="week_today_action">本周</string>
|
||||
<string name="day_today_action">今天</string>
|
||||
<string name="event_detail_back">返回</string>
|
||||
<string name="event_detail_edit">编辑</string>
|
||||
<string name="event_detail_delete">删除</string>
|
||||
<string name="event_detail_share">分享</string>
|
||||
<string name="event_share_chooser_title">分享活动</string>
|
||||
<string name="event_share_failed">无法分享该活动。</string>
|
||||
<string name="event_delete_title">删除活动?</string>
|
||||
<string name="event_delete_body">该活动将从你的日历以及所有同步设备中移除。</string>
|
||||
<string name="event_delete_recurring_title">删除重复活动</string>
|
||||
<string name="event_delete_option_occurrence">仅该活动</string>
|
||||
<string name="event_delete_option_following">当前及接下来的所有活动</string>
|
||||
<string name="event_delete_option_series">系列中的所有活动</string>
|
||||
<string name="event_edit_recurring_title">编辑重复活动</string>
|
||||
<string name="event_delete_failed">无法删除该活动</string>
|
||||
<string name="event_delete_write_denied">Calendula需要权限读取或删除活动</string>
|
||||
<string name="dialog_cancel">取消</string>
|
||||
<string name="dialog_ok">确认</string>
|
||||
<string name="event_edit_new_title">新活动</string>
|
||||
<string name="event_edit_close">关闭</string>
|
||||
<string name="event_edit_save">保存</string>
|
||||
<string name="event_edit_title_hint">增加标题</string>
|
||||
<string name="event_edit_managed_hint">由“%1$s”管理——标题、日期和重复内容会与你的联系人保持同步。提醒、位置和笔记由你自行编辑。</string>
|
||||
<string name="event_edit_starts">开始</string>
|
||||
<string name="event_edit_ends">结束</string>
|
||||
<string name="event_edit_error_end_before_start">报错:结束时间早于起始时间</string>
|
||||
<string name="event_edit_error_no_calendar">无可写入的日历</string>
|
||||
<string name="event_edit_save_failed">无法保存该活动</string>
|
||||
<string name="event_edit_write_denied">Calendula需要写入权限来创建活动</string>
|
||||
<string name="event_edit_more_fields">其他领域</string>
|
||||
<string name="event_edit_add">新增</string>
|
||||
<string name="event_edit_add_reminder">新建提醒</string>
|
||||
<string name="event_edit_remove_reminder">移除提醒</string>
|
||||
<string name="event_edit_attendees">访问者</string>
|
||||
<string name="event_edit_add_guest">新增访问者</string>
|
||||
<string name="event_edit_add_guest_hint">通过 Email 新增一个访问者…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">从联系人中添加访问者</string>
|
||||
<string name="event_edit_location_from_contacts">从联系人中添加地址</string>
|
||||
<string name="event_edit_remove_guest">移除访问者</string>
|
||||
<string name="event_edit_attendee_required">必填</string>
|
||||
<string name="event_edit_attendee_optional">可选项</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula 不发送邀请函。你的日历账户同步时可能会给访客发邮件。</string>
|
||||
<string name="event_edit_attendees_note_local">保存在本设备,其他访客不会被提醒。</string>
|
||||
<string name="event_edit_reminder_custom">自定义</string>
|
||||
<string name="reminder_unit_minutes">分钟</string>
|
||||
<string name="reminder_unit_hours">小时</string>
|
||||
<string name="reminder_unit_days">天</string>
|
||||
<string name="reminder_unit_weeks">周</string>
|
||||
<string name="event_edit_availability">空闲</string>
|
||||
<string name="event_edit_visibility">隐私设置</string>
|
||||
<string name="event_edit_color">色彩</string>
|
||||
<string name="event_edit_color_default">日历颜色</string>
|
||||
<string name="event_edit_color_custom">自定义颜色</string>
|
||||
<string name="event_edit_color_reset">重置</string>
|
||||
<string name="event_edit_color_unsupported">该日历不可用</string>
|
||||
<string name="event_edit_color_unsupported_hint">此日历未设置任何颜色集。您可以在“设置”中为此类日历启用自定义颜色。</string>
|
||||
<string name="event_edit_color_sync_warning">该日历在下一次同步时可能会删除或覆盖该颜色。</string>
|
||||
<string name="event_edit_conflict_title">其他设备的活动已更改</string>
|
||||
<string name="event_edit_conflict_body">您在编辑时,该事件已被同步或其他应用修改。您的修改要怎么处理?</string>
|
||||
<string name="event_edit_conflict_overwrite">保存我的更改</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">仅您编辑的字段会覆盖外部更改</string>
|
||||
<string name="event_edit_conflict_discard">丢弃我的更改</string>
|
||||
<string name="event_edit_conflict_discard_hint">该活动保持现状</string>
|
||||
<string name="event_edit_gone_title">活动被删除</string>
|
||||
<string name="event_edit_gone_body">这个事件在您编辑期间已被删除了(比如在另一台设备上)。您的修改现在无法保存。</string>
|
||||
<string name="event_edit_recurrence_none">确认无需重复</string>
|
||||
<string name="event_edit_recurrence_custom">自定义</string>
|
||||
<string name="event_edit_recurrence_every">每</string>
|
||||
<string name="recurrence_unit_days">天</string>
|
||||
<string name="recurrence_unit_weeks">周</string>
|
||||
<string name="recurrence_unit_months">月</string>
|
||||
<string name="recurrence_unit_years">年</string>
|
||||
<string name="event_edit_recurrence_ends">结束时间</string>
|
||||
<string name="event_edit_recurrence_end_never">从不</string>
|
||||
<string name="event_edit_recurrence_end_until">于指定日期</string>
|
||||
<string name="event_edit_recurrence_end_count">重复几次后</string>
|
||||
<string name="event_edit_recurrence_times">次</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">报错:结束日期早于开始日期</string>
|
||||
<string name="event_availability_busy">占用中</string>
|
||||
<string name="event_access_default">默认</string>
|
||||
<string name="event_access_public">公开</string>
|
||||
</resources>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<!-- Permission flow (F1) -->
|
||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for.</string>
|
||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device.</string>
|
||||
<string name="permission_request_button">Grant calendar access</string>
|
||||
<string name="permission_denied_title">Calendar access denied</string>
|
||||
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
|
||||
@@ -68,6 +68,8 @@
|
||||
<string name="event_edit_close">Close</string>
|
||||
<string name="event_edit_save">Save</string>
|
||||
<string name="event_edit_title_hint">Add title</string>
|
||||
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
|
||||
<string name="event_edit_managed_hint">Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit.</string>
|
||||
<string name="event_edit_starts">Starts</string>
|
||||
<string name="event_edit_ends">Ends</string>
|
||||
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
||||
@@ -197,6 +199,14 @@
|
||||
<item quantity="one">%d hour</item>
|
||||
<item quantity="other">%d hours</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d week</item>
|
||||
<item quantity="other">%d weeks</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
@@ -231,8 +241,7 @@
|
||||
<string name="agenda_today_action">Today</string>
|
||||
<string name="agenda_header_today">Today</string>
|
||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||
<string name="agenda_empty_title">Nothing scheduled</string>
|
||||
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
|
||||
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||
|
||||
<!-- Event search -->
|
||||
<string name="search_action">Search</string>
|
||||
@@ -267,12 +276,59 @@
|
||||
<string name="settings_default_view">Default view</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
<string name="settings_font_headings">Headings font</string>
|
||||
<string name="settings_font_body">Body font</string>
|
||||
<string name="settings_font_system">System default</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">Choose file…</string>
|
||||
<string name="settings_font_custom_selected">Custom font</string>
|
||||
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
|
||||
<string name="settings_week_start">Week starts on</string>
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<string name="settings_week_start_monday">Monday</string>
|
||||
<string name="settings_week_start_sunday">Sunday</string>
|
||||
<string name="settings_time_format">Time format</string>
|
||||
<string name="settings_time_format_auto">Automatic</string>
|
||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||
<string name="settings_hour_lines">Hour lines</string>
|
||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||
<string name="settings_dim_completed">Dim completed events</string>
|
||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||
<string name="settings_past_events">Past events</string>
|
||||
<string name="settings_past_events_show">Show</string>
|
||||
<string name="settings_past_events_dim">Dim</string>
|
||||
<string name="settings_past_events_hide">Hide</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Agenda range</string>
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||
<string name="agenda_range_day">Today</string>
|
||||
<string name="agenda_range_this_week">This week</string>
|
||||
<string name="agenda_range_this_month">This month</string>
|
||||
<string name="agenda_range_week">Next 7 days</string>
|
||||
<string name="agenda_range_month">Next 30 days</string>
|
||||
<string name="agenda_range_custom">Custom…</string>
|
||||
<string name="agenda_range_custom_hint">Days</string>
|
||||
<string name="agenda_range_override_hint">Just for now — resets to your saved range when you reopen Calendula.</string>
|
||||
<string name="agenda_range_showing_label">Showing all upcoming events for</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<string name="settings_section_views">Views</string>
|
||||
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
||||
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
|
||||
<string name="reorder_drag_handle">Drag to reorder</string>
|
||||
<string name="settings_section_event_form">New event form</string>
|
||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
|
||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
@@ -285,8 +341,8 @@
|
||||
<string name="reminder_none">None</string>
|
||||
<string name="reminder_use_default">Use default reminder</string>
|
||||
<string name="reminder_custom_amount">Amount</string>
|
||||
<string name="reminder_custom_with_value">Custom (%1$s)</string>
|
||||
<string name="reminder_custom_set">Set</string>
|
||||
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
||||
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||
@@ -299,10 +355,42 @@
|
||||
<string name="settings_section_language">Language</string>
|
||||
<string name="settings_language">App language</string>
|
||||
<string name="settings_language_auto">System default</string>
|
||||
<string name="settings_translate">Help translate</string>
|
||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="settings_section_special_dates">Contact special dates</string>
|
||||
<string name="settings_special_dates_enable">Show contact dates</string>
|
||||
<string name="settings_special_dates_enable_hint">Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed.</string>
|
||||
<string name="settings_special_dates_type_birthday">Birthdays</string>
|
||||
<string name="settings_special_dates_type_anniversary">Anniversaries</string>
|
||||
<string name="settings_special_dates_type_custom">Other dates</string>
|
||||
<string name="settings_special_dates_template">Title format</string>
|
||||
<string name="settings_special_dates_template_hint">Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown).</string>
|
||||
<string name="settings_special_dates_reminders">Reminders</string>
|
||||
<string name="settings_special_dates_show_year">Show year</string>
|
||||
<string name="settings_special_dates_show_year_hint">Include {year} in titles when it is known</string>
|
||||
<string name="settings_special_dates_sync_now">Sync now</string>
|
||||
<string name="settings_special_dates_never_synced">Not synced yet</string>
|
||||
<!-- %1$s is a relative time, e.g. "5 minutes ago". -->
|
||||
<string name="settings_special_dates_last_synced">Last synced %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Set each calendar\'s colour and visibility in Calendars settings.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Set in Contact special dates</string>
|
||||
<string name="settings_special_dates_paused_title">Paused</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula can no longer read your contacts, so these calendars aren\'t updating.</string>
|
||||
<string name="settings_special_dates_grant">Grant access</string>
|
||||
<string name="settings_special_dates_disable_title">Turn off contact dates?</string>
|
||||
<string name="settings_special_dates_disable_all_message">This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost.</string>
|
||||
<!-- %1$s is the date type, e.g. "Birthdays". -->
|
||||
<string name="settings_special_dates_disable_type_message">This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Turn off</string>
|
||||
<string name="dialog_save">Save</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
<string name="settings_license">License</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
@@ -323,7 +411,10 @@
|
||||
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
|
||||
<string name="calendars_synced_header">Synced calendars</string>
|
||||
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||
<string name="calendars_manage_in_app">Manage</string>
|
||||
<string name="calendars_manage_in_app">Manage in app</string>
|
||||
<string name="calendars_account_menu_a11y">More options for %1$s</string>
|
||||
<string name="calendars_enable_all">Enable all</string>
|
||||
<string name="calendars_disable_all">Disable all</string>
|
||||
<string name="calendars_add_account">Add account</string>
|
||||
<string name="calendars_new_title">New calendar</string>
|
||||
<string name="calendars_edit_title">Edit calendar</string>
|
||||
@@ -337,6 +428,26 @@
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
||||
<string name="calendars_backup_action">Export as .ics file</string>
|
||||
<string name="calendars_auto_backup">Automatic backup</string>
|
||||
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Tap to choose a folder</string>
|
||||
<string name="calendars_auto_backup_interval">Interval</string>
|
||||
<string name="calendars_auto_backup_every">Every %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_status_never">No automatic backup yet</string>
|
||||
<string name="calendars_auto_backup_status_ok">Last backup: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Last backup failed: %1$s</string>
|
||||
<string name="backup_channel_name">Backup</string>
|
||||
<string name="backup_channel_description">Warns if automatic backups fail repeatedly.</string>
|
||||
<string name="backup_failed_title">Automatic backup failed</string>
|
||||
<string name="backup_failed_text">Calendula couldn\'t write the backup file. Check the backup folder in Settings.</string>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Exported %d event.</item>
|
||||
@@ -382,6 +493,7 @@
|
||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
|
||||
|
||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||
Gitea issue (the app sends nothing automatically). -->
|
||||
@@ -397,4 +509,13 @@
|
||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||
|
||||
<!-- Contact special dates (issue #15). {name} is the contact, {age} the age
|
||||
at the upcoming date (dropped when the year is unknown or Show age is off). -->
|
||||
<string name="special_dates_calendar_birthday">Birthdays</string>
|
||||
<string name="special_dates_calendar_anniversary">Anniversaries</string>
|
||||
<string name="special_dates_calendar_custom">Special dates</string>
|
||||
<string name="special_dates_default_title_birthday">{name}\'s birthday ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user