Compare commits
71 Commits
60fcc6b64c
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2279738371 | |||
| 3d59962112 | |||
| a96d8b6de9 | |||
| 94b9eeaa78 | |||
|
|
85fb091e22 | ||
| 2139f8729c | |||
| 98a48aa795 | |||
| d255d232b9 | |||
| 9999fcdd2a | |||
| 953ffdff97 | |||
| 5377a2b466 | |||
| e73148dc6c | |||
| a19772e3a7 | |||
| b3386eff43 | |||
| 79e74d9995 | |||
| 396a5610aa | |||
| b9329f6fb6 | |||
| 1b731a4ab0 | |||
| ab631365b2 | |||
| 9aa370d583 | |||
|
|
8cd75716ae | ||
|
|
4feccf2008 | ||
| 23e9a5a35b | |||
| 38a35be0f0 | |||
| f9f0572ec5 | |||
| 0221972e6d | |||
| 86ea72d56f | |||
| 114db7939c | |||
| 4503847c0d | |||
| 95c5fe76e6 | |||
| 34fa9c9c3e | |||
| 53793bfb68 | |||
| 9c7c8cb03a | |||
| 4fea176e28 | |||
| cf9492c7ba | |||
| e6def9e5f7 | |||
| 974185f354 | |||
| 6aacdd9111 | |||
| 4f263d00fe | |||
| bda002684c | |||
| b6bcd195b0 | |||
| 9fb4502ed0 | |||
| d398c72005 | |||
| 9d718e0f51 | |||
| 8ddc11159b | |||
| 2218c11d3f | |||
|
|
6736bae5ee | ||
| 60aa889c57 | |||
| 3c9767387b | |||
|
|
21d1726d7a | ||
| 1e7ee5b98a | |||
| a82df3f6d0 | |||
| e0e3eb73b9 | |||
| 8a7a0af207 | |||
| 71a652ce3b | |||
| 5fa6eac1ad | |||
| 21ba3fb7cf | |||
| 316c2b0b12 | |||
| ef77eab627 | |||
| 955d47ef12 | |||
| 8536774522 | |||
| a8aeae5f32 | |||
| 988ac009b5 | |||
| 494e486998 | |||
| 3c73028c80 | |||
| 2be1be19fe | |||
| 1f050c2be9 | |||
|
|
5771b603f2 | ||
|
|
5725989aff | ||
| 8b22e1b2af | |||
| a514b8b506 |
@@ -29,6 +29,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# Full history so the base..HEAD diff below has a merge-base.
|
# Full history so the base..HEAD diff below has a merge-base.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
# Cheap, always-on guard: the release build must stay reproducible for the
|
# Cheap, always-on guard: the release build must stay reproducible for the
|
||||||
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
@@ -82,6 +84,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ name: Translations
|
|||||||
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
# Fast, SDK-free parity check for translation resources, so Weblate PRs (which
|
||||||
# only touch values-*/strings.xml) get quick feedback without the full Android
|
# only touch values-*/strings.xml) get quick feedback without the full Android
|
||||||
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
# build. The deeper checks still run in CI via lintDebug (ExtraTranslation).
|
||||||
|
#
|
||||||
|
# Runs on every PR (no path filter) so the required "Translations / check"
|
||||||
|
# status is always reported — like the `ci` job. A path-filtered workflow is
|
||||||
|
# skipped on unrelated PRs and never posts its status, which leaves that
|
||||||
|
# required check pending forever and blocks the merge of any code-only PR into a
|
||||||
|
# release/* branch. The check itself is cheap and simply passes when the
|
||||||
|
# committed translations are consistent, so always running it costs nothing.
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
|
||||||
- 'app/src/main/res/values*/strings.xml'
|
|
||||||
- 'app/src/main/res/xml/locales_config.xml'
|
|
||||||
- 'scripts/check_translations.py'
|
|
||||||
- '.gitea/workflows/translations.yaml'
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: translations-${{ github.ref }}
|
group: translations-${{ github.ref }}
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "floret-kit"]
|
||||||
|
path = floret-kit
|
||||||
|
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||||
136
CHANGELOG.md
@@ -5,14 +5,138 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
||||||
|
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
||||||
|
amount and switch between minutes and hours, so a snoozed reminder comes back
|
||||||
|
after exactly the delay you want instead of only a preset one ([#40]).
|
||||||
|
- Move an event to another calendar. When editing an existing event, the
|
||||||
|
calendar row is now tappable — pick a different calendar and saving moves the
|
||||||
|
event across, instead of having to delete it and recreate it elsewhere.
|
||||||
|
Recurring series move as a whole, keeping their individually-edited and
|
||||||
|
cancelled occurrences, and any reminders and guests come along too. A calendar
|
||||||
|
can't simply be reassigned underneath an event, so Calendula recreates it on
|
||||||
|
the target and removes the original — the same approach other calendar apps
|
||||||
|
take. Thanks to @prismplex for the suggestion ([#39]).
|
||||||
|
- Open an event straight into the edit form from another app. Calendula already
|
||||||
|
answered the "new event" and "open this event" hand-offs from other apps and
|
||||||
|
widgets; it now also answers the "edit this event" one, so an assistant, task
|
||||||
|
app, or widget can send an existing event to Calendula and land on its edit
|
||||||
|
screen rather than the read-only details. A hand-off with no event attached
|
||||||
|
opens the same prefilled create form as "new event". Calendula also recognises
|
||||||
|
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||||
|
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||||
|
into Calendula works too.
|
||||||
|
- Keep today at the top of the agenda. A new **Always show today** setting
|
||||||
|
(Settings → Agenda, on by default) anchors today as the first entry in both the
|
||||||
|
Agenda screen and its home-screen widget even once nothing is left today —
|
||||||
|
under today's header a "No more events today" note appears — so the first
|
||||||
|
events you see are clearly today's rather than a future day's. Turn it off to
|
||||||
|
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
|
||||||
|
- Duplicate an event. The event details now carry a **Duplicate** action that
|
||||||
|
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
|
||||||
|
a one-off like a shift or an appointment can be recreated by just changing the
|
||||||
|
day and time instead of re-typing every field. The copy keeps the original's
|
||||||
|
time, and its title, location, notes, colour, guests and reminders come along;
|
||||||
|
it's saved as its own single event (any repeat is left off — add one in the
|
||||||
|
editor if you want it). Duplicate works from read-only calendars too, dropping
|
||||||
|
the copy into a writable one. Thanks to @internet-rando for the suggestion
|
||||||
|
([#52]).
|
||||||
|
- French and Polish, in early form. Calendula has started speaking French and
|
||||||
|
Polish, both contributed as community translations through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
They are partway there, so untranslated parts still show in English until they
|
||||||
|
fill out — you can already pick either under Settings → Language or in Android's
|
||||||
|
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
|
||||||
|
(Polish) for getting them started; help finishing them is very welcome.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Reminders for events on another day no longer read as if they were today. A
|
||||||
|
reminder fired ahead of time — say, the day before — used to show only the
|
||||||
|
event's time, making it look like it was happening now. The notification now
|
||||||
|
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
||||||
|
week, or the date for anything further out. Thanks to @moonj for the report
|
||||||
|
([#46]).
|
||||||
|
- Agenda dates now read in your locale's format. The agenda's range bar and its
|
||||||
|
day headers used a fixed day-month-year layout — and the range span even mixed
|
||||||
|
two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's
|
||||||
|
conventions. Dates across the agenda and its widget now use your locale's own
|
||||||
|
field order, matching the rest of the app. The range bar also no longer repeats
|
||||||
|
the range's name from the selector button beside it, showing just the dates.
|
||||||
|
- Calendar gutters line up with the menu button. The Month view's week-number
|
||||||
|
column, and the Week and Day views' hour labels, sat a few pixels left of the
|
||||||
|
hamburger menu above them; they now line up with it. In Month view the day
|
||||||
|
cells also sit squarely under their weekday letters.
|
||||||
|
|
||||||
|
## [2.14.1] — 2026-07-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Deleting one occurrence of a repeating event no longer breaks the series.
|
||||||
|
Choosing "This event" when deleting an occurrence of a recurring event could
|
||||||
|
wipe out every *other* occurrence while leaving the one you deleted behind as
|
||||||
|
a stale, still-tappable ghost — and deleting it again brought the series back.
|
||||||
|
A single-occurrence delete now removes exactly that occurrence and leaves the
|
||||||
|
rest of the series untouched, and the deleted occurrence disappears from the
|
||||||
|
grid straight away. This holds on every kind of calendar, including the
|
||||||
|
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
||||||
|
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
||||||
|
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
||||||
|
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
||||||
|
it also answers the "open this event" one, so tapping an existing event in a
|
||||||
|
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
||||||
|
Thanks to @bushrang3r for the report ([#48]).
|
||||||
|
- Events created from other apps get your default reminder. An event handed over
|
||||||
|
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
||||||
|
widget's "+" — opened with no reminder at all, ignoring the default set in
|
||||||
|
Settings. It now starts with your default reminder, the same as an event you
|
||||||
|
create in Calendula. An event opened from an `.ics` file is treated differently,
|
||||||
|
because the file has its own say: Calendula keeps whatever reminders it carries
|
||||||
|
(including none at all) and asks you once whether to apply your default instead
|
||||||
|
— it never quietly overrides the file. If you have no default set, it doesn't
|
||||||
|
ask ([#49]).
|
||||||
|
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
||||||
|
app (such as DAVx5), the event colour picker showed every colour the account
|
||||||
|
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||||
|
duplicates or near-identical shades. The picker now shows only visually
|
||||||
|
distinct colours, arranged as a rainbow; near-duplicate shades and the
|
||||||
|
washed-out neutrals are folded away so no two swatches look alike. Picked
|
||||||
|
colours still sync exactly as before, and calendars with hand-picked
|
||||||
|
palettes (like Google's) are unaffected. Thanks to @ptab for the report
|
||||||
|
([#22]).
|
||||||
|
|
||||||
## [2.14.0] — 2026-07-06
|
## [2.14.0] — 2026-07-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Restore events from a backup file. The backup section of Settings can now read
|
||||||
|
events back **in** from an `.ics` file, not just write one out: pick a file,
|
||||||
|
choose which calendar to import into, and Calendula adds the events — skipping
|
||||||
|
any that are already there and telling you how many it skipped. Export gained a
|
||||||
|
per-calendar selector at the same time, so you can back up just the calendars
|
||||||
|
you pick instead of everything at once ([#32]).
|
||||||
|
- Week numbers in Month view. A new **Week numbers** setting (off by default)
|
||||||
|
adds a slim gutter down the left of the Month grid showing the calendar-week
|
||||||
|
number for each row, sized to match the day cells. Handy if you plan or refer
|
||||||
|
to dates by week number ([#25]).
|
||||||
- Tap a date header to open that day. In Week and Agenda view, tapping a date
|
- Tap a date header to open that day. In Week and Agenda view, tapping a date
|
||||||
header now opens that date in Day view — the same drill-in that Month view and
|
header now opens that date in Day view — the same drill-in that Month view and
|
||||||
the agenda widget already offered, so every view behaves the same way. It makes
|
the agenda widget already offered, so every view behaves the same way. It makes
|
||||||
jumping to a specific day quicker: switch to Week, swipe to the week you want,
|
jumping to a specific day quicker: switch to Week, swipe to the week you want,
|
||||||
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
|
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
|
||||||
|
- An early Simplified Chinese translation. Calendula has started speaking
|
||||||
|
Simplified Chinese, contributed as a community translation through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
It is still an early effort, so many parts of the app show in English until it
|
||||||
|
fills out — you can already pick it under Settings → Language or in Android's
|
||||||
|
per-app language settings. Thanks to
|
||||||
|
[zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it
|
||||||
|
started; help finishing it is very welcome.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Long event titles wrap in the edit screen. When editing an event, a long title
|
||||||
|
now wraps onto multiple lines instead of being clipped to a single line, so you
|
||||||
|
can see and edit the whole thing ([#33]).
|
||||||
|
|
||||||
## [2.13.1] — 2026-07-06
|
## [2.13.1] — 2026-07-06
|
||||||
|
|
||||||
@@ -842,9 +966,21 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||||
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||||
|
[#22]: https://codeberg.org/jlmakiola/calendula/issues/22
|
||||||
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||||
|
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
|
||||||
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
||||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||||
|
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||||
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||||
|
[#39]: https://codeberg.org/jlmakiola/calendula/issues/39
|
||||||
|
[#35]: https://codeberg.org/jlmakiola/calendula/issues/35
|
||||||
|
[#40]: https://codeberg.org/jlmakiola/calendula/issues/40
|
||||||
|
[#46]: https://codeberg.org/jlmakiola/calendula/issues/46
|
||||||
|
[#47]: https://codeberg.org/jlmakiola/calendula/issues/47
|
||||||
|
[#48]: https://codeberg.org/jlmakiola/calendula/issues/48
|
||||||
|
[#49]: https://codeberg.org/jlmakiola/calendula/issues/49
|
||||||
|
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21400
|
versionCode = 21401
|
||||||
versionName = "2.14.0"
|
versionName = "2.14.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -161,6 +161,12 @@ dependencies {
|
|||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-time")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||||
|
implementation("de.jeanlucmakiola.floret:identity")
|
||||||
|
implementation("de.jeanlucmakiola.floret:components")
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|||||||
@@ -97,32 +97,74 @@
|
|||||||
<data android:mimeType="time/epoch" />
|
<data android:mimeType="time/epoch" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
<!-- Open a .ics/.vcs file (file manager / email attachment / browser).
|
||||||
|
The three MIME types cover the common labels the same calendar
|
||||||
|
data arrives under: iCalendar 2.0 (text/calendar), the older
|
||||||
|
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
|
||||||
|
mail apps emit — Android cross-products the scheme and mimeType
|
||||||
|
tags, so each MIME is accepted on both schemes (matches Etar). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="content" android:mimeType="text/calendar" />
|
<data android:scheme="content" />
|
||||||
<data android:scheme="file" android:mimeType="text/calendar" />
|
<data android:scheme="file" />
|
||||||
|
<data android:mimeType="text/calendar" />
|
||||||
|
<data android:mimeType="text/x-vcalendar" />
|
||||||
|
<data android:mimeType="application/ics" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Receive a .ics shared from another app. -->
|
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="text/calendar" />
|
<data android:mimeType="text/calendar" />
|
||||||
|
<data android:mimeType="text/x-vcalendar" />
|
||||||
|
<data android:mimeType="application/ics" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
|
<!-- 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:
|
to create a new event, the way the AOSP calendar accepts it:
|
||||||
ACTION_INSERT on the events dir mime type, carrying the new
|
ACTION_INSERT on the events dir mime type, carrying the new
|
||||||
event's fields as CalendarContract extras
|
event's fields as CalendarContract extras
|
||||||
(MainActivity.insertFormOrNull, issue #30). -->
|
(MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
|
||||||
|
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
|
||||||
|
to the same prefilled create form. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.INSERT" />
|
<action android:name="android.intent.action.INSERT" />
|
||||||
|
<action android:name="android.intent.action.EDIT" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Edit an existing event another app/assistant/widget points at:
|
||||||
|
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
|
||||||
|
AOSP fires it. Opens the occurrence in the edit form (not the
|
||||||
|
read-only detail — that's the VIEW filter above). Matched by the
|
||||||
|
provider's item MIME type, like the VIEW filter. The occurrence's
|
||||||
|
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
|
||||||
|
supplied (MainActivity.editEventKeyOrNull). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.EDIT" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Open an existing event another app/widget points at (e.g. tapping
|
||||||
|
an event in the Todo Agenda widget): ACTION_VIEW on
|
||||||
|
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
||||||
|
Matched by the provider's item MIME type, not the path — a
|
||||||
|
content: VIEW intent carries the resolved type
|
||||||
|
(vnd.android.cursor.item/event) and a path-only filter wouldn't
|
||||||
|
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
|
||||||
|
EXTRA_EVENT_END_TIME when the launcher supplies them
|
||||||
|
(MainActivity.viewEventKeyOrNull, issue #48). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
|||||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -24,8 +25,18 @@ class CalendulaApp : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// Install first thing so startup crashes are captured too (privacy-
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
// respecting, on-device; the user submits the report by hand).
|
// respecting, on-device; the user submits the report by hand). The
|
||||||
CrashReporter.install(this)
|
// capture/loop-detection/report machinery lives in floret-kit's
|
||||||
|
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
||||||
|
CrashReporter.install(
|
||||||
|
this,
|
||||||
|
CrashConfig(
|
||||||
|
appLabel = getString(R.string.app_name),
|
||||||
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
|
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||||
|
issueTitle = getString(R.string.crash_report_issue_title),
|
||||||
|
),
|
||||||
|
)
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import androidx.core.net.toUri
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -33,12 +32,14 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||||
|
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||||
@@ -69,6 +70,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// CalendarHost, which opens it in the create form for review.
|
// CalendarHost, which opens it in the create form for review.
|
||||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||||
|
|
||||||
|
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||||
|
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||||
|
// detail channel; consumed once by CalendarHost.
|
||||||
|
private var requestedEditKey by mutableStateOf<LongArray?>(null)
|
||||||
|
|
||||||
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
// over the calendar on the next launch (the single-crash path). A startup
|
// over the calendar on the next launch (the single-crash path). A startup
|
||||||
// crash-loop is handled out of band, before setContent — see below.
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
@@ -90,10 +96,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsertForm = intent.insertFormOrNull()
|
requestedInsertForm = intent.insertFormOrNull()
|
||||||
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
// One activity-scoped SettingsViewModel drives both the theme here
|
// One activity-scoped SettingsViewModel drives both the theme here
|
||||||
@@ -144,6 +151,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
onInsertConsumed = { requestedInsertForm = null },
|
onInsertConsumed = { requestedInsertForm = null },
|
||||||
|
requestedEditKey = requestedEditKey,
|
||||||
|
onEditKeyConsumed = { requestedEditKey = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// A persistent corner marker so a debug build is never
|
// A persistent corner marker so a debug build is never
|
||||||
@@ -178,10 +187,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||||
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,13 +212,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A prefilled new-event form from an external `ACTION_INSERT` launch — another
|
* A prefilled new-event form from an external launch asking us to create an
|
||||||
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30).
|
* event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
|
||||||
* The new event's fields ride as CalendarContract extras; anything omitted
|
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||||
* falls back to the in-app "new event" defaults in [buildInsertEventForm].
|
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||||
|
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||||
|
* [buildInsertEventForm].
|
||||||
*/
|
*/
|
||||||
private fun Intent.insertFormOrNull(): EventForm? {
|
private fun Intent.insertFormOrNull(): EventForm? {
|
||||||
if (action != Intent.ACTION_INSERT) return null
|
// ACTION_EDIT on an existing event routes to the edit form instead
|
||||||
|
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
|
||||||
|
val isCreate = action == Intent.ACTION_INSERT ||
|
||||||
|
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
|
||||||
|
if (!isCreate) return null
|
||||||
return buildInsertEventForm(
|
return buildInsertEventForm(
|
||||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||||
@@ -293,6 +309,55 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The detail key for an external "open this event" — ACTION_VIEW on
|
||||||
|
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||||
|
* tapping an existing event in the Todo Agenda widget, issue #48). Reuses the
|
||||||
|
* same occurrence-key channel as reminder taps. The launcher passes the
|
||||||
|
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when
|
||||||
|
* it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and
|
||||||
|
* [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND
|
||||||
|
* rather than rendering at the epoch.
|
||||||
|
*/
|
||||||
|
private fun Intent.viewEventKeyOrNull(): LongArray? {
|
||||||
|
if (action != Intent.ACTION_VIEW) return null
|
||||||
|
val uri = data ?: return null
|
||||||
|
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||||
|
val segments = uri.pathSegments
|
||||||
|
if (segments.firstOrNull() != "events") return null
|
||||||
|
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||||
|
return longArrayOf(
|
||||||
|
eventId,
|
||||||
|
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||||
|
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
|
||||||
|
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||||
|
* an assistant, task app, or widget that wants to open the event for editing
|
||||||
|
* rather than viewing). Opens it in the edit form. Reuses the same
|
||||||
|
* occurrence-key channel as reminder/view taps; the caller passes the
|
||||||
|
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
||||||
|
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
||||||
|
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
||||||
|
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
|
||||||
|
*/
|
||||||
|
private fun Intent.editEventKeyOrNull(): LongArray? {
|
||||||
|
if (action != Intent.ACTION_EDIT) return null
|
||||||
|
val uri = data ?: return null
|
||||||
|
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||||
|
val segments = uri.pathSegments
|
||||||
|
if (segments.firstOrNull() != "events") return null
|
||||||
|
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||||
|
return longArrayOf(
|
||||||
|
eventId,
|
||||||
|
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||||
|
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// The calendar provider's authority/host. A date tap arrives as
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
@@ -16,10 +18,13 @@ import android.util.Log
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
@@ -61,10 +66,12 @@ interface CalendarDataSource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette the calendar's account publishes
|
* The event-colour palette the calendar's account publishes
|
||||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
|
||||||
* account exposes no palette (most local calendars, some CalDAV) — the
|
* thinned to visually distinct swatches when oversized (CalDAV adapters
|
||||||
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
* publish all ~147 CSS3 names, #22) and hue-sorted; see [curatedForPicker].
|
||||||
* which a synced calendar may drop on its next sync.
|
* Empty when the account exposes no palette (most local calendars, some
|
||||||
|
* CalDAV) — the signal that a custom colour can only be written as a raw
|
||||||
|
* `EVENT_COLOR`, which a synced calendar may drop on its next sync.
|
||||||
*/
|
*/
|
||||||
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
fun eventColorPalette(calendarId: Long): List<EventColorOption>
|
||||||
|
|
||||||
@@ -174,6 +181,25 @@ interface CalendarDataSource {
|
|||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an event (for recurring events: the whole series, with its modified
|
||||||
|
* and cancelled occurrences) to [targetCalendarId], returning the new
|
||||||
|
* `Events._ID`. `CALENDAR_ID` is sync-adapter-owned and can't be updated in
|
||||||
|
* place, so this is copy+delete: the row is re-inserted on the target (its
|
||||||
|
* `UID_2445` preserved), its exceptions, reminders and editable guests
|
||||||
|
* replayed, the [original]→[updated] field edits applied, then the source
|
||||||
|
* deleted. The source is removed only once the copy fully succeeds; a failure
|
||||||
|
* before that rolls the new event back, leaving the original untouched.
|
||||||
|
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
|
*/
|
||||||
|
fun moveEvent(
|
||||||
|
eventId: Long,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event by inserting a
|
* Change a single occurrence of a recurring event by inserting a
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* modified-occurrence exception at [beginMillis] (the occurrence's
|
||||||
@@ -490,7 +516,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
return resolver.query(
|
return resolver.query(
|
||||||
uri,
|
uri,
|
||||||
InstanceProjection.COLUMNS,
|
InstanceProjection.COLUMNS,
|
||||||
null, null,
|
// Hide cancelled occurrences: "delete only this event" writes a
|
||||||
|
// cancelled exception for the one instance (#47). A NULL status is a
|
||||||
|
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||||
|
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||||
|
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||||
|
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
||||||
|
null,
|
||||||
CalendarContract.Instances.BEGIN + " ASC",
|
CalendarContract.Instances.BEGIN + " ASC",
|
||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
@@ -609,7 +641,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||||
}
|
}
|
||||||
?.filter { it.key.isNotEmpty() }
|
?.filter { it.key.isNotEmpty() }
|
||||||
?.sortedBy { it.key }
|
?.curatedForPicker()
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -874,6 +906,190 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun moveEvent(
|
||||||
|
eventId: Long,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
|
// CALENDAR_ID can't be updated in place, so re-create the event on the
|
||||||
|
// target calendar and delete the source. Everything that identifies or
|
||||||
|
// hangs off the event is copied first; the source row is removed only
|
||||||
|
// once the copy (including its exceptions) is complete — a failure
|
||||||
|
// anywhere before that rolls the new event back, so the move is
|
||||||
|
// all-or-nothing and never leaves a half-copied duplicate.
|
||||||
|
val master = queryMoveMaster(eventId)
|
||||||
|
?: throw WriteFailedException("read event to move id=$eventId")
|
||||||
|
// Keep the source UID so an .ics backup dedups and a sync adapter can
|
||||||
|
// recognise the moved event; mint one only if the source carried none.
|
||||||
|
val uid = master.uid?.takeIf { it.isNotEmpty() } ?: "${UUID.randomUUID()}@calendula"
|
||||||
|
val newEventId = resolver.insert(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
buildMovedMasterValues(master.event, targetCalendarId, uid).toContentValues(),
|
||||||
|
)?.let(ContentUris::parseId)
|
||||||
|
?: throw WriteFailedException("insert moved event into calendar id=$targetCalendarId")
|
||||||
|
try {
|
||||||
|
copyReminderRows(fromEventId = eventId, toEventId = newEventId)
|
||||||
|
insertAttendees(newEventId, editableAttendees(eventId))
|
||||||
|
copyExceptions(fromEventId = eventId, toEventId = newEventId)
|
||||||
|
// Apply the user's field edits exactly as a same-calendar "all
|
||||||
|
// events" save would: the moved master carries the source's values,
|
||||||
|
// so the dirty diff against [original] writes only what changed
|
||||||
|
// (including a time or rrule edit made in the same save).
|
||||||
|
updateEvent(newEventId, original, updated, allDayReminderTimeMinutes)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// Undo the partial copy so a failed move leaves nothing behind; the
|
||||||
|
// source is still intact (it's deleted only past this point).
|
||||||
|
runCatching { deleteEvent(newEventId) }
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
deleteEvent(eventId)
|
||||||
|
return newEventId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The master row of the event to move, as a verbatim-insert snapshot. */
|
||||||
|
private fun queryMoveMaster(eventId: Long): MoveMaster? = resolver.query(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
MoveMasterProjection.COLUMNS,
|
||||||
|
null, null, null,
|
||||||
|
)?.use { c ->
|
||||||
|
if (!c.moveToFirst()) return@use null
|
||||||
|
val r = CursorColumnReader(c)
|
||||||
|
MoveMaster(
|
||||||
|
uid = r.getString(MoveMasterProjection.IDX_UID),
|
||||||
|
event = MasterEventSnapshot(
|
||||||
|
title = r.getString(MoveMasterProjection.IDX_TITLE).orEmpty(),
|
||||||
|
isAllDay = r.getInt(MoveMasterProjection.IDX_ALL_DAY) != 0,
|
||||||
|
dtStartMillis = r.getLong(MoveMasterProjection.IDX_DTSTART),
|
||||||
|
dtEndMillis = r.getLong(MoveMasterProjection.IDX_DTEND)
|
||||||
|
.takeUnless { r.isNull(MoveMasterProjection.IDX_DTEND) },
|
||||||
|
duration = r.getString(MoveMasterProjection.IDX_DURATION),
|
||||||
|
rrule = r.getString(MoveMasterProjection.IDX_RRULE)?.takeIf { it.isNotBlank() },
|
||||||
|
rdate = r.getString(MoveMasterProjection.IDX_RDATE),
|
||||||
|
exdate = r.getString(MoveMasterProjection.IDX_EXDATE),
|
||||||
|
timezone = r.getString(MoveMasterProjection.IDX_EVENT_TIMEZONE),
|
||||||
|
availability = r.getInt(MoveMasterProjection.IDX_AVAILABILITY),
|
||||||
|
accessLevel = r.getInt(MoveMasterProjection.IDX_ACCESS_LEVEL),
|
||||||
|
status = r.getInt(MoveMasterProjection.IDX_STATUS)
|
||||||
|
.takeUnless { r.isNull(MoveMasterProjection.IDX_STATUS) },
|
||||||
|
location = r.getString(MoveMasterProjection.IDX_LOCATION),
|
||||||
|
description = r.getString(MoveMasterProjection.IDX_DESCRIPTION),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class MoveMaster(val uid: String?, val event: MasterEventSnapshot)
|
||||||
|
|
||||||
|
/** Copy every stored reminder row (raw offsets and method) onto [toEventId]. */
|
||||||
|
private fun copyReminderRows(fromEventId: Long, toEventId: Long) = resolver.query(
|
||||||
|
CalendarContract.Reminders.CONTENT_URI,
|
||||||
|
arrayOf(CalendarContract.Reminders.MINUTES, CalendarContract.Reminders.METHOD),
|
||||||
|
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||||
|
arrayOf(fromEventId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Reminders.EVENT_ID, toEventId)
|
||||||
|
put(CalendarContract.Reminders.MINUTES, c.getInt(0))
|
||||||
|
put(CalendarContract.Reminders.METHOD, c.getInt(1))
|
||||||
|
}
|
||||||
|
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, values) == null) {
|
||||||
|
Log.w(TAG, "Failed to copy a reminder to moved event $toEventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's editable guests as [EventAttendee]s — the rows the move
|
||||||
|
* re-creates. Mirrors the edit form's filter: the organizer and resource
|
||||||
|
* rows (backend-owned, not user-editable) and any without an email are
|
||||||
|
* dropped; the response status resets to "invited" on re-insert.
|
||||||
|
*/
|
||||||
|
private fun editableAttendees(eventId: Long): List<EventAttendee> = queryAttendees(eventId)
|
||||||
|
.filter {
|
||||||
|
it.relationship != AttendeeRelationship.Organizer && it.type != AttendeeType.Resource
|
||||||
|
}
|
||||||
|
.mapNotNull { a ->
|
||||||
|
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||||
|
EventAttendee(email = email, name = a.name, optional = a.type == AttendeeType.Optional)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay every exception of the series [fromEventId] against the moved series
|
||||||
|
* [toEventId]. The copy preserved the recurrence skeleton, so each occurrence
|
||||||
|
* time still resolves: a cancelled occurrence is re-hidden with a cancelled
|
||||||
|
* exception, a modified one is re-inserted with its fields (then its reminders
|
||||||
|
* reconciled and editable guests copied). No-op for a non-recurring event.
|
||||||
|
*/
|
||||||
|
private fun copyExceptions(fromEventId: Long, toEventId: Long) {
|
||||||
|
queryExceptionRows(fromEventId).forEach { ex ->
|
||||||
|
if (ex.isCancelled) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, ex.originalInstanceMillis)
|
||||||
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
|
}
|
||||||
|
resolver.insert(
|
||||||
|
ContentUris.withAppendedId(
|
||||||
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
||||||
|
),
|
||||||
|
values,
|
||||||
|
) ?: throw WriteFailedException("copy cancelled occurrence to event id=$toEventId")
|
||||||
|
} else {
|
||||||
|
val uri = resolver.insert(
|
||||||
|
ContentUris.withAppendedId(
|
||||||
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, toEventId,
|
||||||
|
),
|
||||||
|
buildCopiedExceptionValues(ex).toContentValues(),
|
||||||
|
) ?: throw WriteFailedException("copy modified occurrence to event id=$toEventId")
|
||||||
|
val newExceptionId = ContentUris.parseId(uri)
|
||||||
|
// The provider may clone the parent's reminders onto the new
|
||||||
|
// exception; reconcile to the source exception's exact set so
|
||||||
|
// they neither double nor drop. Same DTSTART → same all-day
|
||||||
|
// encoding, so the stored offsets match directly.
|
||||||
|
reconcileReminders(
|
||||||
|
newExceptionId,
|
||||||
|
queryReminders(ex.exceptionEventId).map { it.minutes },
|
||||||
|
)
|
||||||
|
insertAttendees(newExceptionId, editableAttendees(ex.exceptionEventId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The series' exception rows (modified + cancelled), oldest occurrence first. */
|
||||||
|
private fun queryExceptionRows(seriesEventId: Long): List<ExceptionRowSnapshot> = resolver.query(
|
||||||
|
CalendarContract.Events.CONTENT_URI,
|
||||||
|
ExceptionProjection.COLUMNS,
|
||||||
|
"${CalendarContract.Events.ORIGINAL_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
|
||||||
|
arrayOf(seriesEventId.toString()),
|
||||||
|
CalendarContract.Events.ORIGINAL_INSTANCE_TIME + " ASC",
|
||||||
|
)?.use { c ->
|
||||||
|
c.mapAll {
|
||||||
|
val r = CursorColumnReader(c)
|
||||||
|
val status = r.getInt(ExceptionProjection.IDX_STATUS)
|
||||||
|
.takeUnless { r.isNull(ExceptionProjection.IDX_STATUS) }
|
||||||
|
ExceptionRowSnapshot(
|
||||||
|
exceptionEventId = r.getLong(ExceptionProjection.IDX_ID),
|
||||||
|
originalInstanceMillis = r.getLong(ExceptionProjection.IDX_ORIGINAL_INSTANCE_TIME),
|
||||||
|
isCancelled = status == CalendarContract.Events.STATUS_CANCELED,
|
||||||
|
status = status,
|
||||||
|
title = r.getString(ExceptionProjection.IDX_TITLE).orEmpty(),
|
||||||
|
isAllDay = r.getInt(ExceptionProjection.IDX_ALL_DAY) != 0,
|
||||||
|
dtStartMillis = r.getLong(ExceptionProjection.IDX_DTSTART),
|
||||||
|
dtEndMillis = r.getLong(ExceptionProjection.IDX_DTEND)
|
||||||
|
.takeUnless { r.isNull(ExceptionProjection.IDX_DTEND) },
|
||||||
|
duration = r.getString(ExceptionProjection.IDX_DURATION),
|
||||||
|
timezone = r.getString(ExceptionProjection.IDX_EVENT_TIMEZONE),
|
||||||
|
availability = r.getInt(ExceptionProjection.IDX_AVAILABILITY),
|
||||||
|
accessLevel = r.getInt(ExceptionProjection.IDX_ACCESS_LEVEL),
|
||||||
|
location = r.getString(ExceptionProjection.IDX_LOCATION),
|
||||||
|
description = r.getString(ExceptionProjection.IDX_DESCRIPTION),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
@@ -968,6 +1184,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
CalendarContract.Events.ALL_DAY,
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events._SYNC_ID,
|
||||||
|
CalendarContract.Events.EXDATE,
|
||||||
),
|
),
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
@@ -978,6 +1196,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
timezone = c.getString(2),
|
timezone = c.getString(2),
|
||||||
duration = c.getString(3),
|
duration = c.getString(3),
|
||||||
allDay = c.getInt(4),
|
allDay = c.getInt(4),
|
||||||
|
syncId = c.getString(5),
|
||||||
|
exdate = c.getString(6),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -990,6 +1210,9 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
val timezone: String?,
|
val timezone: String?,
|
||||||
val duration: String?,
|
val duration: String?,
|
||||||
val allDay: Int,
|
val allDay: Int,
|
||||||
|
/** Null on a local calendar (and before a synced event's first push). */
|
||||||
|
val syncId: String? = null,
|
||||||
|
val exdate: String? = null,
|
||||||
) {
|
) {
|
||||||
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
/** UNTIL cutoff for ending the series before the occurrence at [beginMillis]. */
|
||||||
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
fun truncationCutoff(beginMillis: Long): Long = previousLocalDayEndUtcMillis(
|
||||||
@@ -1180,16 +1403,49 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||||
// A cancelled exception row hides exactly this occurrence; the sync
|
val row = querySeriesRow(eventId)
|
||||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
|
if (row.syncId == null) {
|
||||||
val values = ContentValues().apply {
|
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
||||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
// A cancelled exception can only attach to its parent through
|
||||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
// ORIGINAL_SYNC_ID, so with none the link never forms and the
|
||||||
|
// provider's expansion of the *parent* collapses, taking every other
|
||||||
|
// occurrence with it (#47 on a local calendar). EXDATE needs no link.
|
||||||
|
// Calendula's own contact special-date calendars are local and hold
|
||||||
|
// yearly series, so this path is reached in normal use.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = row.exdate,
|
||||||
|
occurrenceMillis = beginMillis,
|
||||||
|
dtStartMillis = row.dtStartMillis,
|
||||||
|
rrule = row.rrule,
|
||||||
|
duration = row.duration,
|
||||||
|
timezone = row.timezone,
|
||||||
|
allDay = row.allDay,
|
||||||
|
)
|
||||||
|
val updated = resolver.update(
|
||||||
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
values.toContentValues(), null, null,
|
||||||
|
)
|
||||||
|
if (updated == 0) {
|
||||||
|
throw WriteFailedException("exdate occurrence event id=$eventId begin=$beginMillis")
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
// A cancelled exception row hides exactly this occurrence; the sync
|
||||||
|
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It carries
|
||||||
|
// the full time set (DTSTART + DURATION + zone) so the provider derives a
|
||||||
|
// single instance rather than cloning the master's RRULE — the same trap
|
||||||
|
// the edit path documents (Codeberg #16).
|
||||||
|
val values = buildOccurrenceCancelValues(
|
||||||
|
originalInstanceMillis = beginMillis,
|
||||||
|
dtStartMillis = beginMillis,
|
||||||
|
duration = row.duration,
|
||||||
|
timezone = row.timezone,
|
||||||
|
allDay = row.allDay,
|
||||||
|
)
|
||||||
val uri = ContentUris.withAppendedId(
|
val uri = ContentUris.withAppendedId(
|
||||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||||
)
|
)
|
||||||
resolver.insert(uri, values)
|
resolver.insert(uri, values.toContentValues())
|
||||||
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ interface CalendarRepository {
|
|||||||
*/
|
*/
|
||||||
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
suspend fun updateEvent(eventId: Long, original: EventForm, updated: EventForm)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an event (recurring: the whole series, with its exceptions) to
|
||||||
|
* [targetCalendarId] and apply the [original]→[updated] field edits; returns
|
||||||
|
* the new event's `Events._ID`. Copy+delete under the hood
|
||||||
|
* (see [CalendarDataSource.moveEvent]) — `CALENDAR_ID` can't be updated in
|
||||||
|
* place.
|
||||||
|
*/
|
||||||
|
suspend fun moveEvent(
|
||||||
|
eventId: Long,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event (exception row with the
|
* Change a single occurrence of a recurring event (exception row with the
|
||||||
* form's values); returns the exception's `Events._ID`.
|
* form's values); returns the exception's `Events._ID`.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
@@ -156,6 +157,17 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.deleteEvent(eventId)
|
dataSource.deleteEvent(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun moveEvent(
|
||||||
|
eventId: Long,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
): Long = withContext(io) {
|
||||||
|
dataSource.moveEvent(
|
||||||
|
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun updateOccurrence(
|
override suspend fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
@@ -13,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -35,15 +37,18 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
// Recurring events store DURATION instead of DTEND, so the series row's
|
// Recurring events store DURATION instead of DTEND, so the series row's
|
||||||
// DTEND is null. Keep the event (end == begin); callers that opened a
|
// DTEND is null — derive the length from DURATION (as SearchMapper and
|
||||||
// specific occurrence supply the real per-occurrence times from
|
// IcsExportMapper do). Callers that opened a specific occurrence overwrite
|
||||||
// CalendarContract.Instances. A present-but-backwards DTEND is malformed,
|
// both times with the per-occurrence values from CalendarContract.Instances;
|
||||||
// but dropping the row would make the event un-openable — the same trap as
|
// a caller that names no occurrence (a bare content://.../events/<id> VIEW
|
||||||
// the pre-1970 DTSTART bug above (issue #34): it would surface as the
|
// intent, issue #48) keeps this row's own times, so the length has to be
|
||||||
// generic error screen with no way to open the event and fix it. Clamp to a
|
// right here or the series renders zero-length. A present-but-backwards
|
||||||
// zero-length event instead (matching SearchMapper's coerceAtLeast).
|
// DTEND is malformed, but dropping the row would make the event un-openable
|
||||||
|
// — the same trap as the pre-1970 DTSTART bug above (issue #34): it would
|
||||||
|
// surface as the generic error screen with no way to open the event and fix
|
||||||
|
// it. Clamp to a zero-length event instead (matching SearchMapper).
|
||||||
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
|
||||||
begin
|
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
|
||||||
} else {
|
} else {
|
||||||
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,19 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||||
* events, seconds otherwise.
|
* events, seconds otherwise.
|
||||||
*/
|
*/
|
||||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
||||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
|
||||||
|
* events (the provider's convention), seconds otherwise. Shared by the write
|
||||||
|
* paths that need a DURATION but start from a raw millisecond span (the series
|
||||||
|
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
|
||||||
|
*/
|
||||||
|
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
|
||||||
|
"P${spanMillis / MILLIS_PER_DAY}D"
|
||||||
} else {
|
} else {
|
||||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
"P${spanMillis / 1_000L}S"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,6 +191,215 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||||
|
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||||
|
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
|
||||||
|
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
|
||||||
|
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
|
||||||
|
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
|
||||||
|
* account, so the moved copy inherits the target calendar's colour instead.
|
||||||
|
*/
|
||||||
|
internal data class MasterEventSnapshot(
|
||||||
|
val title: String,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val dtEndMillis: Long?,
|
||||||
|
val duration: String?,
|
||||||
|
val rrule: String?,
|
||||||
|
val rdate: String?,
|
||||||
|
val exdate: String?,
|
||||||
|
val timezone: String?,
|
||||||
|
val availability: Int,
|
||||||
|
val accessLevel: Int,
|
||||||
|
val status: Int?,
|
||||||
|
val location: String?,
|
||||||
|
val description: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values re-creating [snapshot] as a fresh Events row on
|
||||||
|
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
|
||||||
|
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
|
||||||
|
* RDATE/EXDATE) so the series' generated instances — and therefore the
|
||||||
|
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
|
||||||
|
* caller layers the user's field edits on top with a normal series update.
|
||||||
|
*/
|
||||||
|
internal fun buildMovedMasterValues(
|
||||||
|
snapshot: MasterEventSnapshot,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
uid: String,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
|
||||||
|
put(CalendarContract.Events.UID_2445, uid)
|
||||||
|
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||||
|
if (snapshot.rrule != null) {
|
||||||
|
put(CalendarContract.Events.RRULE, snapshot.rrule)
|
||||||
|
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
|
||||||
|
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||||
|
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
|
||||||
|
} else {
|
||||||
|
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
|
||||||
|
}
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||||
|
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||||
|
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||||
|
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
|
||||||
|
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
|
||||||
|
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider snapshot of one exception row of a recurring series (a modified
|
||||||
|
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
|
||||||
|
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
|
||||||
|
*/
|
||||||
|
internal data class ExceptionRowSnapshot(
|
||||||
|
val exceptionEventId: Long,
|
||||||
|
val originalInstanceMillis: Long,
|
||||||
|
val isCancelled: Boolean,
|
||||||
|
val status: Int?,
|
||||||
|
val title: String,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val dtEndMillis: Long?,
|
||||||
|
val duration: String?,
|
||||||
|
val timezone: String?,
|
||||||
|
val availability: Int,
|
||||||
|
val accessLevel: Int,
|
||||||
|
val location: String?,
|
||||||
|
val description: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values replaying a *modified* occurrence [snapshot] against the moved
|
||||||
|
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
|
||||||
|
* length travels as DURATION (the provider rejects DTEND on an exception). A
|
||||||
|
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
|
||||||
|
* STATUS_CANCELED) — this builder is only for the modified case.
|
||||||
|
*/
|
||||||
|
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
|
||||||
|
buildMap {
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
|
||||||
|
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
snapshot.duration?.takeIf { it.isNotBlank() }
|
||||||
|
?: rfc2445Duration(
|
||||||
|
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
|
||||||
|
snapshot.isAllDay,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||||
|
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||||
|
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||||
|
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
||||||
|
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||||
|
* provider clone the series row and cancel exactly this one instance.
|
||||||
|
*
|
||||||
|
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
|
||||||
|
* DTSTART + DURATION so the provider derives a single instance and clears the
|
||||||
|
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
|
||||||
|
* the *whole series* is cancelled and every other occurrence disappears
|
||||||
|
* (Codeberg #47). The occurrence's length/zone come straight from the series row
|
||||||
|
* — cancelling never changes them.
|
||||||
|
*/
|
||||||
|
internal fun buildOccurrenceCancelValues(
|
||||||
|
originalInstanceMillis: Long,
|
||||||
|
dtStartMillis: Long,
|
||||||
|
duration: String?,
|
||||||
|
timezone: String?,
|
||||||
|
allDay: Int,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
||||||
|
put(CalendarContract.Events.DTSTART, dtStartMillis)
|
||||||
|
put(CalendarContract.Events.DURATION, duration)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, allDay)
|
||||||
|
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The master-row columns that drop the occurrence at [occurrenceMillis] from a
|
||||||
|
* series by adding it to `EXDATE` — the path for events that have **no
|
||||||
|
* `_sync_id`** (a local calendar, or a synced event not yet pushed).
|
||||||
|
*
|
||||||
|
* A cancelled exception row (see [buildOccurrenceCancelValues]) only attaches to
|
||||||
|
* its parent through `ORIGINAL_SYNC_ID`. Without a `_sync_id` the link never
|
||||||
|
* forms, and the provider's expansion of the *parent* collapses — every other
|
||||||
|
* occurrence disappears (Codeberg #47, reproduced on a local calendar). EXDATE
|
||||||
|
* needs no link, and is the canonical iCalendar way to drop an occurrence, so a
|
||||||
|
* sync adapter carries it upstream unchanged if the calendar later syncs.
|
||||||
|
*
|
||||||
|
* The whole time/recurrence set is rewritten alongside it on purpose. The
|
||||||
|
* provider does **not** treat an EXDATE-only update as a recurrence change: it
|
||||||
|
* leaves the expanded `Instances` rows untouched, so the occurrence stays visible
|
||||||
|
* (and, symmetrically, un-excluding one leaves it hidden). Writing DTSTART with
|
||||||
|
* it forces the re-expansion — but DTSTART *alone* makes the provider recompute
|
||||||
|
* `lastDate` as if the event were a single instance, collapsing the series to its
|
||||||
|
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
|
||||||
|
* re-expands it correctly. All observed on a Pixel; see the #47 notes.
|
||||||
|
*
|
||||||
|
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat
|
||||||
|
* of the same occurrence is folded away). All-day series take the `VALUE=DATE`
|
||||||
|
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`).
|
||||||
|
*/
|
||||||
|
internal fun buildOccurrenceExdateValues(
|
||||||
|
existingExdate: String?,
|
||||||
|
occurrenceMillis: Long,
|
||||||
|
dtStartMillis: Long,
|
||||||
|
rrule: String?,
|
||||||
|
duration: String?,
|
||||||
|
timezone: String?,
|
||||||
|
allDay: Int,
|
||||||
|
): Map<String, Any?> {
|
||||||
|
val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
|
||||||
|
val existing = existingExdate?.split(',')
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotEmpty() }
|
||||||
|
.orEmpty()
|
||||||
|
val merged = (existing + stamp).distinct().joinToString(",")
|
||||||
|
return mapOf(
|
||||||
|
CalendarContract.Events.EXDATE to merged,
|
||||||
|
CalendarContract.Events.DTSTART to dtStartMillis,
|
||||||
|
CalendarContract.Events.RRULE to rrule,
|
||||||
|
CalendarContract.Events.DURATION to duration,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE to timezone,
|
||||||
|
CalendarContract.Events.ALL_DAY to allDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms
|
||||||
|
* are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
|
||||||
|
* reads off the UTC calendar day.
|
||||||
|
*/
|
||||||
|
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
|
||||||
|
val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
|
||||||
|
return if (isAllDay) {
|
||||||
|
"%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
|
||||||
|
} else {
|
||||||
|
"%04d%02d%02dT%02d%02d%02dZ".format(
|
||||||
|
utc.year, utc.monthValue, utc.dayOfMonth,
|
||||||
|
utc.hour, utc.minute, utc.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ internal object EventDetailProjection {
|
|||||||
CalendarContract.Events.EVENT_TIMEZONE,
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
CalendarContract.Events.EVENT_COLOR_KEY,
|
CalendarContract.Events.EVENT_COLOR_KEY,
|
||||||
|
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||||
|
// needs it to render a series opened without a named occurrence.
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -104,6 +107,7 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_EVENT_TIMEZONE = 15
|
const val IDX_EVENT_TIMEZONE = 15
|
||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
|
const val IDX_DURATION = 18
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,6 +190,86 @@ internal object SearchProjection {
|
|||||||
const val IDX_RDATE = 11
|
const val IDX_RDATE = 11
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The master/one-off Events row of an event about to be moved to another
|
||||||
|
* calendar, read for a verbatim re-insert (see [MasterEventSnapshot]). Carries
|
||||||
|
* the full recurrence skeleton (RRULE/DURATION, RDATE/EXDATE) so the moved copy
|
||||||
|
* generates the same instances, and `UID_2445` so identity survives the move.
|
||||||
|
*/
|
||||||
|
internal object MoveMasterProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Events.UID_2445,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.DTEND,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.RRULE,
|
||||||
|
CalendarContract.Events.RDATE,
|
||||||
|
CalendarContract.Events.EXDATE,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_UID = 0
|
||||||
|
const val IDX_TITLE = 1
|
||||||
|
const val IDX_DTSTART = 2
|
||||||
|
const val IDX_DTEND = 3
|
||||||
|
const val IDX_DURATION = 4
|
||||||
|
const val IDX_RRULE = 5
|
||||||
|
const val IDX_RDATE = 6
|
||||||
|
const val IDX_EXDATE = 7
|
||||||
|
const val IDX_EVENT_TIMEZONE = 8
|
||||||
|
const val IDX_ALL_DAY = 9
|
||||||
|
const val IDX_AVAILABILITY = 10
|
||||||
|
const val IDX_ACCESS_LEVEL = 11
|
||||||
|
const val IDX_STATUS = 12
|
||||||
|
const val IDX_LOCATION = 13
|
||||||
|
const val IDX_DESCRIPTION = 14
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exception rows of a recurring series (`ORIGINAL_ID` = the series), read to
|
||||||
|
* replay them against a moved copy (see [ExceptionRowSnapshot]). Both modified
|
||||||
|
* occurrences and cancellations (`STATUS_CANCELED`) are read; the query filters
|
||||||
|
* `DELETED = 0` so provider tombstones aren't replayed.
|
||||||
|
*/
|
||||||
|
internal object ExceptionProjection {
|
||||||
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
|
CalendarContract.Events._ID,
|
||||||
|
CalendarContract.Events.ORIGINAL_INSTANCE_TIME,
|
||||||
|
CalendarContract.Events.STATUS,
|
||||||
|
CalendarContract.Events.TITLE,
|
||||||
|
CalendarContract.Events.ALL_DAY,
|
||||||
|
CalendarContract.Events.DTSTART,
|
||||||
|
CalendarContract.Events.DTEND,
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
CalendarContract.Events.EVENT_TIMEZONE,
|
||||||
|
CalendarContract.Events.AVAILABILITY,
|
||||||
|
CalendarContract.Events.ACCESS_LEVEL,
|
||||||
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val IDX_ID = 0
|
||||||
|
const val IDX_ORIGINAL_INSTANCE_TIME = 1
|
||||||
|
const val IDX_STATUS = 2
|
||||||
|
const val IDX_TITLE = 3
|
||||||
|
const val IDX_ALL_DAY = 4
|
||||||
|
const val IDX_DTSTART = 5
|
||||||
|
const val IDX_DTEND = 6
|
||||||
|
const val IDX_DURATION = 7
|
||||||
|
const val IDX_EVENT_TIMEZONE = 8
|
||||||
|
const val IDX_AVAILABILITY = 9
|
||||||
|
const val IDX_ACCESS_LEVEL = 10
|
||||||
|
const val IDX_LOCATION = 11
|
||||||
|
const val IDX_DESCRIPTION = 12
|
||||||
|
}
|
||||||
|
|
||||||
internal object AttendeeProjection {
|
internal object AttendeeProjection {
|
||||||
val COLUMNS: Array<String> = arrayOf(
|
val COLUMNS: Array<String> = arrayOf(
|
||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
|
||||||
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
|
|
||||||
|
|
||||||
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()
|
|
||||||
@@ -4,7 +4,7 @@ import android.provider.CalendarContract
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -111,13 +111,13 @@ class SpecialDatesSyncEngine @Inject constructor(
|
|||||||
* all-day override so new events keep matching. No-op if the calendar for
|
* all-day override so new events keep matching. No-op if the calendar for
|
||||||
* [type] doesn't exist yet.
|
* [type] doesn't exist yet.
|
||||||
*/
|
*/
|
||||||
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
suspend fun applyReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||||
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||||
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||||
val minutes = when (override) {
|
val minutes = when (override) {
|
||||||
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
ReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||||
CalendarReminderOverride.None -> emptyList()
|
ReminderOverride.None -> emptyList()
|
||||||
is CalendarReminderOverride.Minutes -> override.minutes
|
is ReminderOverride.Minutes -> override.minutes
|
||||||
}
|
}
|
||||||
calendars.applyManagedCalendarReminders(
|
calendars.applyManagedCalendarReminders(
|
||||||
calendarId = calendarId,
|
calendarId = calendarId,
|
||||||
@@ -169,7 +169,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
|||||||
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||||
prefs.setCalendarAllDayReminderOverride(
|
prefs.setCalendarAllDayReminderOverride(
|
||||||
id,
|
id,
|
||||||
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
ReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.crash
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageInfo
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.content.pm.PackageInfoCompat
|
|
||||||
import java.io.File
|
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.io.StringWriter
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
|
|
||||||
* exception it writes a self-contained report to the app's private storage and
|
|
||||||
* then chains to the platform's default handler, so the process still dies
|
|
||||||
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
|
|
||||||
* the app holds no `INTERNET` permission. The user submits the report later,
|
|
||||||
* by hand, as a Gitea issue (see the ui/crash surfaces).
|
|
||||||
*
|
|
||||||
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
|
||||||
* version, locale, time, and the stack trace — and **nothing else**: no device
|
|
||||||
* identifiers, no account names, no calendar/event content, no logcat. The user
|
|
||||||
* is always shown the full text before it leaves the device.
|
|
||||||
*/
|
|
||||||
object CrashReporter {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
|
|
||||||
* also catches crashes during startup. The handler swallows nothing — it
|
|
||||||
* persists, then delegates to the previously-registered handler.
|
|
||||||
*/
|
|
||||||
fun install(context: Context) {
|
|
||||||
val appContext = context.applicationContext
|
|
||||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
|
||||||
// Capturing must never mask the original crash, so guard every step.
|
|
||||||
runCatching {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
|
|
||||||
recordCrashTime(appContext, now)
|
|
||||||
}
|
|
||||||
previous?.uncaughtException(thread, throwable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The persisted report from the last crash, or null if there is none. */
|
|
||||||
fun pendingReport(context: Context): String? {
|
|
||||||
val file = reportFile(context)
|
|
||||||
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to surface the report unprompted (on the next launch): a report
|
|
||||||
* exists and the user hasn't already waved this one away. Settings reaches
|
|
||||||
* the report via [pendingReport] regardless, so "Not now" only stops the
|
|
||||||
* auto-prompt — it doesn't discard the report.
|
|
||||||
*/
|
|
||||||
fun shouldPrompt(context: Context): Boolean =
|
|
||||||
reportFile(context).exists() && !dismissedFile(context).exists()
|
|
||||||
|
|
||||||
/** Stop auto-prompting for the current report without discarding it. */
|
|
||||||
fun dismissPrompt(context: Context) {
|
|
||||||
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the persisted report once the user has reported it (or from Settings). */
|
|
||||||
fun clearReport(context: Context) {
|
|
||||||
runCatching { reportFile(context).delete() }
|
|
||||||
runCatching { dismissedFile(context).delete() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the app appears to be in a startup crash-loop: at least
|
|
||||||
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
|
||||||
* can't be trusted to start, so the caller routes straight to the standalone
|
|
||||||
* report screen instead of re-entering the crashing graph.
|
|
||||||
*/
|
|
||||||
fun isCrashLoop(context: Context): Boolean {
|
|
||||||
val times = readCrashTimes(context)
|
|
||||||
if (times.size < LOOP_THRESHOLD) return false
|
|
||||||
val recent = times.sortedDescending()
|
|
||||||
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the app as having started successfully, resetting the loop counter so
|
|
||||||
* an ordinary single crash much later never trips loop detection. The
|
|
||||||
* pending report itself is kept — only the timing trail is cleared.
|
|
||||||
*/
|
|
||||||
fun markHealthy(context: Context) {
|
|
||||||
runCatching { timesFile(context).delete() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- persistence -------------------------------------------------------
|
|
||||||
|
|
||||||
private fun writeReport(context: Context, report: String) {
|
|
||||||
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
|
||||||
file.writeText(report.take(MAX_REPORT_CHARS))
|
|
||||||
// A fresh crash should prompt again, even if the previous one was waved away.
|
|
||||||
runCatching { dismissedFile(context).delete() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
|
||||||
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
|
||||||
timesFile(context).apply { parentFile?.mkdirs() }
|
|
||||||
.writeText(kept.joinToString("\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readCrashTimes(context: Context): List<Long> {
|
|
||||||
val file = timesFile(context)
|
|
||||||
if (!file.exists()) return emptyList()
|
|
||||||
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
|
||||||
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
|
||||||
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
|
||||||
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
|
||||||
|
|
||||||
private const val CRASH_DIR = "crash"
|
|
||||||
private const val REPORT_FILE = "last_crash.txt"
|
|
||||||
private const val TIMES_FILE = "crash_times.txt"
|
|
||||||
private const val DISMISSED_FILE = "dismissed"
|
|
||||||
private const val MAX_TIMES = 5
|
|
||||||
private const val MAX_REPORT_CHARS = 64 * 1024
|
|
||||||
private const val LOOP_THRESHOLD = 2
|
|
||||||
private const val LOOP_WINDOW_MS = 10_000L
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The allowlist of non-personal facts that go into a crash report. Built from
|
|
||||||
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
|
||||||
*/
|
|
||||||
data class CrashContext(
|
|
||||||
val appVersionName: String,
|
|
||||||
val appVersionCode: Long,
|
|
||||||
val sdkInt: Int,
|
|
||||||
val androidRelease: String,
|
|
||||||
val manufacturer: String,
|
|
||||||
val model: String,
|
|
||||||
val locale: String,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun from(context: Context): CrashContext {
|
|
||||||
val pkg = runCatching {
|
|
||||||
context.packageManager.getPackageInfo(context.packageName, 0)
|
|
||||||
}.getOrNull()
|
|
||||||
return CrashContext(
|
|
||||||
appVersionName = pkg?.versionName ?: "?",
|
|
||||||
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
|
||||||
sdkInt = Build.VERSION.SDK_INT,
|
|
||||||
androidRelease = Build.VERSION.RELEASE ?: "?",
|
|
||||||
manufacturer = Build.MANUFACTURER ?: "?",
|
|
||||||
model = Build.MODEL ?: "?",
|
|
||||||
locale = Locale.getDefault().toLanguageTag(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
|
||||||
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
|
|
||||||
* tested. The leading marker doubles as the file's sanity check in
|
|
||||||
* [CrashReporter.pendingReport].
|
|
||||||
*/
|
|
||||||
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
|
|
||||||
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
|
||||||
val time = runCatching {
|
|
||||||
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
|
||||||
}.getOrDefault(nowMillis.toString())
|
|
||||||
return buildString {
|
|
||||||
appendLine("Calendula crash report")
|
|
||||||
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
|
||||||
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
|
||||||
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
|
||||||
appendLine("Locale: ${ctx.locale}")
|
|
||||||
appendLine("Time: $time")
|
|
||||||
appendLine()
|
|
||||||
appendLine("Stack trace:")
|
|
||||||
append(trace)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
|
||||||
@@ -7,6 +7,11 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
@@ -200,6 +205,19 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the Month grid shows the calendar-week (ISO) number in a left
|
||||||
|
* gutter (#25). Defaults to OFF — users opt in, since it narrows the day
|
||||||
|
* cells slightly. The Week view shows its number unconditionally.
|
||||||
|
*/
|
||||||
|
val showWeekNumbers: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[SHOW_WEEK_NUMBERS_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setShowWeekNumbers(enabled: Boolean) {
|
||||||
|
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||||
@@ -234,6 +252,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the agenda (both the in-app screen and the home-screen widget)
|
||||||
|
* always anchors today at the top with a "nothing left today" placeholder,
|
||||||
|
* even once today has no remaining events (issue #35). Default ON — makes
|
||||||
|
* today's events easy to tell apart from a future day's at a glance.
|
||||||
|
*/
|
||||||
|
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAgendaShowToday(enabled: Boolean) {
|
||||||
|
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||||
* the historical hard-coded startup view — so existing users see no change
|
* the historical hard-coded startup view — so existing users see no change
|
||||||
@@ -436,14 +468,14 @@ class SettingsPrefs @Inject constructor(
|
|||||||
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||||
*/
|
*/
|
||||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
current.applyOverride(calendarId, override)
|
current.applyReminderOverride(calendarId, override)
|
||||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,18 +485,18 @@ class SettingsPrefs @Inject constructor(
|
|||||||
* inherit the global all-day default; present null = no reminder).
|
* inherit the global all-day default; present null = no reminder).
|
||||||
*/
|
*/
|
||||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setCalendarAllDayReminderOverride(
|
suspend fun setCalendarAllDayReminderOverride(
|
||||||
calendarId: Long,
|
calendarId: Long,
|
||||||
override: CalendarReminderOverride,
|
override: ReminderOverride,
|
||||||
) {
|
) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
val current =
|
val current =
|
||||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
current.applyOverride(calendarId, override)
|
current.applyReminderOverride(calendarId, override)
|
||||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -655,10 +687,13 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||||
|
internal val AGENDA_SHOW_TODAY_KEY =
|
||||||
|
booleanPreferencesKey("agenda_show_today")
|
||||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
|
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
@@ -727,28 +762,6 @@ data class BackupStatus(
|
|||||||
val consecutiveFailures: Int,
|
val consecutiveFailures: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
|
||||||
sealed interface CalendarReminderOverride {
|
|
||||||
/** No override — the calendar uses the global default. */
|
|
||||||
data object Inherit : CalendarReminderOverride
|
|
||||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
|
||||||
data object None : CalendarReminderOverride
|
|
||||||
/** Specific lead times in minutes before the event start (non-empty). */
|
|
||||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stored override for [calendarId] in an override map, as a
|
|
||||||
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
|
|
||||||
* empty → [CalendarReminderOverride.None]) — the inverse of how
|
|
||||||
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
|
|
||||||
*/
|
|
||||||
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
|
||||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
|
||||||
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
|
||||||
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lead times to prefill on a new event: the matching per-calendar override
|
* The lead times to prefill on a new event: the matching per-calendar override
|
||||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
@@ -773,21 +786,6 @@ fun resolveDefaultReminder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
|
||||||
* key; [None] and an empty [Minutes] both store the empty list).
|
|
||||||
*/
|
|
||||||
private fun MutableMap<Long, List<Int>>.applyOverride(
|
|
||||||
calendarId: Long,
|
|
||||||
override: CalendarReminderOverride,
|
|
||||||
) {
|
|
||||||
when (override) {
|
|
||||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
|
||||||
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
|
||||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||||
private const val WEEK_START_AUTO = "AUTO"
|
private const val WEEK_START_AUTO = "AUTO"
|
||||||
|
|
||||||
@@ -812,8 +810,14 @@ private const val ENTRY_SEP = ";"
|
|||||||
private const val KEY_VALUE_SEP = "="
|
private const val KEY_VALUE_SEP = "="
|
||||||
private const val LIST_SEP = ","
|
private const val LIST_SEP = ","
|
||||||
|
|
||||||
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
/**
|
||||||
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
* The per-calendar override map codec, in Calendula's stored dialect
|
||||||
|
* (`id=minutes` entries joined by `;`, minutes comma-joined, `none` for an
|
||||||
|
* explicit no-reminder). The model and codec live in floret-kit; the dialect
|
||||||
|
* (fixed at release) stays here.
|
||||||
|
*/
|
||||||
|
private val reminderOverrideCodec =
|
||||||
|
ReminderOverrideCodec(entrySep = ENTRY_SEP, keyValueSep = KEY_VALUE_SEP, listSep = LIST_SEP, noneToken = NONE)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||||
@@ -829,25 +833,6 @@ private fun String?.toReminderList(): List<Int> = when {
|
|||||||
private fun List<Int>.toStoredReminders(): String =
|
private fun List<Int>.toStoredReminders(): String =
|
||||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||||
|
|
||||||
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
|
||||||
if (stored.isNullOrBlank()) return emptyMap()
|
|
||||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
|
||||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
|
||||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
|
||||||
// Only the deliberate "none" sentinel means an explicit no-reminder
|
|
||||||
// override (empty list); a non-sentinel value that parses to no valid
|
|
||||||
// minutes is garbage and drops the entry, so the calendar inherits the
|
|
||||||
// global default rather than silently reading as "no reminder".
|
|
||||||
when (val value = parts[1]) {
|
|
||||||
NONE -> id to emptyList()
|
|
||||||
else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it }
|
|
||||||
?: return@mapNotNull null
|
|
||||||
}
|
|
||||||
}.toMap()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
|
||||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.isoDayNumber
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -55,13 +59,22 @@ class ReminderNotifier @Inject constructor(
|
|||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
|
val zone = ZoneId.systemDefault()
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
// resolveFirstDay yields a kotlinx.datetime day; bridge it to java.time by
|
||||||
|
// its shared ISO number (1..7) for the date math in reminderTimeText.
|
||||||
|
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
|
||||||
val time = reminderTimeText(
|
val time = reminderTimeText(
|
||||||
beginMillis = alert.beginMillis,
|
beginMillis = alert.beginMillis,
|
||||||
endMillis = alert.endMillis,
|
endMillis = alert.endMillis,
|
||||||
isAllDay = alert.isAllDay,
|
isAllDay = alert.isAllDay,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = zone,
|
||||||
locale = Locale.getDefault(),
|
locale = locale,
|
||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
|
today = Instant.now().atZone(zone).toLocalDate(),
|
||||||
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
|
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
|
||||||
|
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
|
||||||
)
|
)
|
||||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
|||||||
@@ -1,23 +1,37 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
|
import java.time.DayOfWeek
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one line of time context in a reminder notification. Pure so it can be
|
* The one line of time context in a reminder notification. Pure so it can be
|
||||||
* JVM-tested:
|
* JVM-tested.
|
||||||
*
|
*
|
||||||
* - timed, same day: "09:30 – 10:00"
|
* Timed events that fall on a day other than [today] are prefixed with that
|
||||||
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time)
|
* day, so a reminder fired ahead of time no longer reads as if the event were
|
||||||
|
* today (issue #46). The prefix prefers natural language and stays short:
|
||||||
|
*
|
||||||
|
* - today: "09:30 – 10:00" (no prefix)
|
||||||
|
* - tomorrow / yesterday: "Tomorrow, 09:30 – 10:00" ([tomorrowLabel] / [yesterdayLabel])
|
||||||
|
* - elsewhere this week: "Thu, 09:30 – 10:00" (localized short weekday)
|
||||||
|
* - further out: "16 Jul, 09:30 – 10:00" (medium date — a weekday
|
||||||
|
* alone would be ambiguous)
|
||||||
|
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
|
||||||
|
* already unambiguous)
|
||||||
* - all-day, one day: "11 Jun 2026"
|
* - all-day, one day: "11 Jun 2026"
|
||||||
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
* - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
|
||||||
*
|
*
|
||||||
* All-day instances store UTC midnights with an exclusive end, so they are
|
* All-day instances already carry an explicit date, so they never gain a
|
||||||
|
* relative prefix. They store UTC midnights with an exclusive end, so they are
|
||||||
* read in UTC and the end day is the last *covered* day.
|
* read in UTC and the end day is the last *covered* day.
|
||||||
*/
|
*/
|
||||||
fun reminderTimeText(
|
fun reminderTimeText(
|
||||||
@@ -27,6 +41,10 @@ fun reminderTimeText(
|
|||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
is24Hour: Boolean,
|
is24Hour: Boolean,
|
||||||
|
today: LocalDate,
|
||||||
|
firstDayOfWeek: DayOfWeek,
|
||||||
|
tomorrowLabel: String,
|
||||||
|
yesterdayLabel: String,
|
||||||
): String {
|
): String {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
@@ -43,18 +61,70 @@ fun reminderTimeText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||||
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
val range = timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||||
|
val prefix = relativeDayPrefix(
|
||||||
|
day = begin.toLocalDate(),
|
||||||
|
today = today,
|
||||||
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
|
locale = locale,
|
||||||
|
dateFormat = dateFormat,
|
||||||
|
tomorrowLabel = tomorrowLabel,
|
||||||
|
yesterdayLabel = yesterdayLabel,
|
||||||
|
)
|
||||||
|
if (prefix == null) range else "$prefix, $range"
|
||||||
} else {
|
} else {
|
||||||
// Cross-day: medium date + the chosen short time, joined per side. Built
|
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||||
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||||
// applies to the time portion too.
|
// applies to the time portion too. The explicit dates already say which
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
// day, so no relative prefix is layered on top.
|
||||||
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||||
dateTime(begin) + RANGE + dateTime(end)
|
dateTime(begin) + RANGE + dateTime(end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short label for [day] relative to [today], or `null` when it *is* today (the
|
||||||
|
* common case, which needs no prefix). Weekday names are used only within the
|
||||||
|
* current week — a "next Wednesday" would be indistinguishable from this one, so
|
||||||
|
* anything past this week falls back to the exact date.
|
||||||
|
*/
|
||||||
|
private fun relativeDayPrefix(
|
||||||
|
day: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
firstDayOfWeek: DayOfWeek,
|
||||||
|
locale: Locale,
|
||||||
|
dateFormat: DateTimeFormatter,
|
||||||
|
tomorrowLabel: String,
|
||||||
|
yesterdayLabel: String,
|
||||||
|
): String? = when (ChronoUnit.DAYS.between(today, day)) {
|
||||||
|
0L -> null
|
||||||
|
1L -> tomorrowLabel
|
||||||
|
-1L -> yesterdayLabel
|
||||||
|
else -> if (isSameWeek(day, today, firstDayOfWeek)) {
|
||||||
|
day.dayOfWeek.getDisplayName(TextStyle.SHORT, locale)
|
||||||
|
} else {
|
||||||
|
dateFormat.format(day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [day] and [today] share the same week. The week boundary honours the
|
||||||
|
* user's *week starts on* setting (already resolved to a concrete [firstDayOfWeek],
|
||||||
|
* with [firstDayOfWeek] falling back to the locale default upstream).
|
||||||
|
*/
|
||||||
|
private fun isSameWeek(day: LocalDate, today: LocalDate, firstDayOfWeek: DayOfWeek): Boolean {
|
||||||
|
val startOfWeek = today.previousOrSame(firstDayOfWeek)
|
||||||
|
return !day.isBefore(startOfWeek) && day.isBefore(startOfWeek.plusWeeks(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most recent [target] on or before this date (this date itself when it matches). */
|
||||||
|
private fun LocalDate.previousOrSame(target: DayOfWeek): LocalDate {
|
||||||
|
val backtrack = (dayOfWeek.value - target.value + 7) % 7
|
||||||
|
return minusDays(backtrack.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
private const val RANGE = " – "
|
private const val RANGE = " – "
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cbrt
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curates an account's published event palette for the colour picker.
|
||||||
|
*
|
||||||
|
* Sync adapters differ wildly in what they publish: Google exposes a
|
||||||
|
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
|
||||||
|
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
|
||||||
|
* spelling pairs) and dozens of visually indistinguishable whites and grays
|
||||||
|
* (#22).
|
||||||
|
*
|
||||||
|
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||||
|
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
||||||
|
* it pins lightness to a constant and caps saturation, so the raw palette's
|
||||||
|
* lightness axis is invisible on screen. Two raw colours that look different —
|
||||||
|
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
||||||
|
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
||||||
|
* space, as before, left near-identical painted swatches and stranded the
|
||||||
|
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
||||||
|
*
|
||||||
|
* Three steps, all in painted space:
|
||||||
|
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||||
|
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||||
|
* hue, and all the neutrals together.
|
||||||
|
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
||||||
|
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
||||||
|
* then thinned to visually distinct colours: most vivid first, a colour is
|
||||||
|
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
||||||
|
* colour already kept. Small palettes are already curated by their adapter
|
||||||
|
* and pass through whole.
|
||||||
|
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||||
|
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||||
|
* seam lands in dead space and no hue family is torn across both ends.
|
||||||
|
*
|
||||||
|
* Every surviving option keeps its provider [EventColorOption.key], so a pick
|
||||||
|
* still round-trips through sync.
|
||||||
|
*/
|
||||||
|
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||||
|
val painted = sortedBy { it.key }
|
||||||
|
.distinctBy { pastelArgb(it.argb) }
|
||||||
|
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||||
|
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||||
|
painted
|
||||||
|
} else {
|
||||||
|
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||||
|
}
|
||||||
|
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders swatches continuously around the (painted) hue wheel, then cuts the
|
||||||
|
* circle at its widest angular gap so the single seam lands in empty space
|
||||||
|
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||||
|
*/
|
||||||
|
private fun orderAroundWheel(
|
||||||
|
swatches: List<Pair<EventColorOption, Lab>>,
|
||||||
|
): List<Pair<EventColorOption, Lab>> {
|
||||||
|
if (swatches.size < 2) return swatches
|
||||||
|
val byHue = swatches.sortedWith(
|
||||||
|
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
||||||
|
)
|
||||||
|
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||||
|
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||||
|
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||||
|
var cutAfter = byHue.lastIndex
|
||||||
|
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
||||||
|
for (i in 0 until byHue.lastIndex) {
|
||||||
|
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||||
|
if (gap > widestGap) {
|
||||||
|
widestGap = gap
|
||||||
|
cutAfter = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byHue.subList(cutAfter + 1, byHue.size) + byHue.subList(0, cutAfter + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||||
|
private fun thin(
|
||||||
|
swatches: List<Pair<EventColorOption, Lab>>,
|
||||||
|
): List<Pair<EventColorOption, Lab>> {
|
||||||
|
val byVividness = swatches
|
||||||
|
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||||
|
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||||
|
for (candidate in byVividness) {
|
||||||
|
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The softening the colour picker paints over every swatch: keep the hue, scale
|
||||||
|
* and clamp saturation into a gentle band, and pin value to a constant so
|
||||||
|
* nothing screams and everything reads on the surface. Value is fixed here so
|
||||||
|
* curation is theme-independent — only hue and saturation distinguish painted
|
||||||
|
* swatches.
|
||||||
|
*
|
||||||
|
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
|
||||||
|
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
|
||||||
|
* rather than theme-picked. Curation must reason about the colour the picker
|
||||||
|
* paints, so the two shapings have to agree: if floret's saturation band or
|
||||||
|
* curve changes, update this in step.
|
||||||
|
*/
|
||||||
|
fun pastelArgb(rawArgb: Int): Int {
|
||||||
|
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
||||||
|
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
||||||
|
val b = (rawArgb and 0xFF) / 255f
|
||||||
|
val max = maxOf(r, g, b)
|
||||||
|
val min = minOf(r, g, b)
|
||||||
|
val delta = max - min
|
||||||
|
val hue = when {
|
||||||
|
delta == 0f -> 0f
|
||||||
|
max == r -> 60f * (((g - b) / delta) % 6f)
|
||||||
|
max == g -> 60f * (((b - r) / delta) + 2f)
|
||||||
|
else -> 60f * (((r - g) / delta) + 4f)
|
||||||
|
}.let { if (it < 0f) it + 360f else it }
|
||||||
|
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
||||||
|
val s = sat.coerceIn(0.25f, 0.65f)
|
||||||
|
val v = PASTEL_VALUE
|
||||||
|
val c = v * s
|
||||||
|
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
||||||
|
val m = v - c
|
||||||
|
val (rr, gg, bb) = when {
|
||||||
|
hue < 60f -> Triple(c, x, 0f)
|
||||||
|
hue < 120f -> Triple(x, c, 0f)
|
||||||
|
hue < 180f -> Triple(0f, c, x)
|
||||||
|
hue < 240f -> Triple(0f, x, c)
|
||||||
|
hue < 300f -> Triple(x, 0f, c)
|
||||||
|
else -> Triple(c, 0f, x)
|
||||||
|
}
|
||||||
|
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
||||||
|
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
||||||
|
private const val PASTEL_VALUE = 0.82f
|
||||||
|
|
||||||
|
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||||
|
private const val CURATION_TRIGGER_SIZE = 36
|
||||||
|
|
||||||
|
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
||||||
|
private const val MIN_DELTA_E = 13.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
||||||
|
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
||||||
|
* is dropped rather than shown as pale filler.
|
||||||
|
*/
|
||||||
|
private const val PASTEL_CHROMA_FLOOR = 22.0
|
||||||
|
|
||||||
|
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||||
|
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||||
|
val chroma: Double get() = hypot(a, b)
|
||||||
|
|
||||||
|
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
||||||
|
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||||
|
|
||||||
|
fun deltaE(other: Lab): Double =
|
||||||
|
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(argb: Int): Lab {
|
||||||
|
fun linear(shift: Int): Double {
|
||||||
|
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||||
|
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||||
|
}
|
||||||
|
val r = linear(16)
|
||||||
|
val g = linear(8)
|
||||||
|
val b = linear(0)
|
||||||
|
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||||
|
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||||
|
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||||
|
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
||||||
|
val fy = f(y)
|
||||||
|
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
@@ -33,6 +33,7 @@ import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
|
|||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||||
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
import de.jeanlucmakiola.calendula.ui.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
import de.jeanlucmakiola.calendula.ui.search.SearchScreen
|
||||||
@@ -74,6 +75,8 @@ fun CalendarHost(
|
|||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: EventForm? = null,
|
requestedInsertForm: EventForm? = null,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
|
requestedEditKey: LongArray? = null,
|
||||||
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
viewModel: CalendarHostViewModel = hiltViewModel(),
|
viewModel: CalendarHostViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
// Wait for the persisted default view before seeding the stack, so the app
|
// Wait for the persisted default view before seeding the stack, so the app
|
||||||
@@ -172,6 +175,9 @@ fun CalendarHost(
|
|||||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||||
|
// Which channel filled [importForm]: an .ics file (prompt to apply the default
|
||||||
|
// reminder) or an ACTION_INSERT intent (apply it automatically) — #49.
|
||||||
|
var importFormSource by remember { mutableStateOf(ImportSource.File) }
|
||||||
// A restore (in-app "Restore from .ics" button) always runs the full import
|
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||||
// flow — picker + summary — even for a single-event file, because the intent
|
// flow — picker + summary — even for a single-event file, because the intent
|
||||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||||
@@ -190,6 +196,7 @@ fun CalendarHost(
|
|||||||
// reveals on top of whatever was open without extra dismissal.
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
LaunchedEffect(requestedInsertForm) {
|
LaunchedEffect(requestedInsertForm) {
|
||||||
if (requestedInsertForm != null) {
|
if (requestedInsertForm != null) {
|
||||||
|
importFormSource = ImportSource.Insert
|
||||||
importForm = requestedInsertForm
|
importForm = requestedInsertForm
|
||||||
onInsertConsumed()
|
onInsertConsumed()
|
||||||
}
|
}
|
||||||
@@ -207,6 +214,20 @@ fun CalendarHost(
|
|||||||
importForm = null
|
importForm = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||||
|
// widget) opens the occurrence straight in the edit form. Drop any covering
|
||||||
|
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
||||||
|
// without this it would open hidden underneath them. Same held-key pattern as
|
||||||
|
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
||||||
|
LaunchedEffect(requestedEditKey) {
|
||||||
|
if (requestedEditKey != null) {
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
heldEditKey = requestedEditKey
|
||||||
|
editKey = requestedEditKey
|
||||||
|
onEditKeyConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||||
@@ -280,7 +301,7 @@ fun CalendarHost(
|
|||||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
val viewSwitch = calendarFadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
@@ -363,6 +384,14 @@ fun CalendarHost(
|
|||||||
heldEditKey = key
|
heldEditKey = key
|
||||||
editKey = key
|
editKey = key
|
||||||
},
|
},
|
||||||
|
onDuplicate = { form ->
|
||||||
|
// Reuse the prefilled-create overlay: a duplicate is a
|
||||||
|
// fresh event, so it applies the default reminder like an
|
||||||
|
// in-app new event (#52).
|
||||||
|
importFormSource = ImportSource.Insert
|
||||||
|
importForm = form
|
||||||
|
detailKey = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,6 +464,7 @@ fun CalendarHost(
|
|||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
|
importFormSource = ImportSource.File
|
||||||
importForm = form
|
importForm = form
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -443,6 +473,7 @@ fun CalendarHost(
|
|||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = null,
|
initialDateIso = null,
|
||||||
initialForm = form,
|
initialForm = form,
|
||||||
|
initialFormSource = importFormSource,
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ fun RootScreen(
|
|||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
|
requestedEditKey: LongArray? = null,
|
||||||
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
@@ -88,6 +90,8 @@ fun RootScreen(
|
|||||||
onImportConsumed = onImportConsumed,
|
onImportConsumed = onImportConsumed,
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsertForm,
|
||||||
onInsertConsumed = onInsertConsumed,
|
onInsertConsumed = onInsertConsumed,
|
||||||
|
requestedEditKey = requestedEditKey,
|
||||||
|
onEditKeyConsumed = onEditKeyConsumed,
|
||||||
)
|
)
|
||||||
false -> ReminderOnboardingScreen(
|
false -> ReminderOnboardingScreen(
|
||||||
onFinished = reminderOnboarding::finish,
|
onFinished = reminderOnboarding::finish,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The concrete span the range covers, starting at [start] through [end]
|
* The concrete span the range covers, starting at [start] through [end]
|
||||||
* (inclusive), for a human-readable header:
|
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
|
||||||
|
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
|
||||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||||
|
* year shown too only when it differs from the end's.
|
||||||
*/
|
*/
|
||||||
fun agendaRangeWindowSummary(
|
fun agendaRangeWindowSummary(
|
||||||
range: AgendaRange,
|
range: AgendaRange,
|
||||||
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
|
|||||||
): String {
|
): String {
|
||||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||||
|
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||||
return when (range) {
|
return when (range) {
|
||||||
AgendaRange.Day -> medium.format(javaStart)
|
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
else -> {
|
||||||
|
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||||
|
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.DateRange
|
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -59,19 +60,20 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -83,7 +85,6 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
@@ -106,6 +107,7 @@ fun AgendaScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -170,9 +172,11 @@ fun AgendaScreen(
|
|||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
// end aligns the selector's right edge with the top-bar view
|
||||||
|
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
AgendaRangeBanner(
|
AgendaRangeBanner(
|
||||||
range = s.range,
|
range = s.range,
|
||||||
@@ -190,6 +194,7 @@ fun AgendaScreen(
|
|||||||
AgendaContent(
|
AgendaContent(
|
||||||
state = state,
|
state = state,
|
||||||
pastDisplay = pastDisplay,
|
pastDisplay = pastDisplay,
|
||||||
|
showToday = showToday,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
@@ -213,9 +218,11 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
* The agenda's current range, tapped to open the range picker as a session-only
|
||||||
* range picker as a session-only override. Filled with the primary container
|
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||||
* while an override is active, so the temporary state is obvious.
|
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
|
||||||
|
* switcher's secondary container, so it doesn't compete. Fills with the primary
|
||||||
|
* container only while an override is active, to make that temporary state clear.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaRangePill(
|
private fun AgendaRangePill(
|
||||||
@@ -224,43 +231,34 @@ private fun AgendaRangePill(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val container = if (isOverride) {
|
FilledTonalButton(
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
onClick = onClick,
|
||||||
} else {
|
shape = MaterialTheme.shapes.large,
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
}
|
containerColor = if (isOverride) {
|
||||||
val content = if (isOverride) {
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
} else {
|
||||||
} else {
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
},
|
||||||
}
|
contentColor = if (isOverride) {
|
||||||
Surface(
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
color = container,
|
} else {
|
||||||
contentColor = content,
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
shape = RoundedCornerShape(50),
|
},
|
||||||
modifier = modifier.clickable(onClick = onClick),
|
),
|
||||||
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
Row(
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
text = agendaRangeLabel(range),
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
style = MaterialTheme.typography.labelLarge,
|
||||||
) {
|
)
|
||||||
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
|
* A header naming the concrete window currently shown under a "showing …" label,
|
||||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||||
|
* lives on the selector button beside it, so it isn't repeated here.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaRangeBanner(
|
private fun AgendaRangeBanner(
|
||||||
@@ -277,8 +275,10 @@ private fun AgendaRangeBanner(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
// Just the concrete dates — the range's name ("Next 30 days") already
|
||||||
|
// sits on the selector button to the right, so repeating it here is noise.
|
||||||
Text(
|
Text(
|
||||||
text = "${agendaRangeLabel(range)}, $window",
|
text = window,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
@@ -289,6 +289,7 @@ private fun AgendaRangeBanner(
|
|||||||
private fun AgendaContent(
|
private fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
pastDisplay: PastEventDisplay,
|
pastDisplay: PastEventDisplay,
|
||||||
|
showToday: Boolean,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
@@ -304,7 +305,7 @@ private fun AgendaContent(
|
|||||||
// Hiding drops finished events — and any day they leave empty; dimming
|
// Hiding drops finished events — and any day they leave empty; dimming
|
||||||
// keeps them but fades the row. Recomputed each minute so events fall
|
// keeps them but fades the row. Recomputed each minute so events fall
|
||||||
// away (or fade) as they end while the screen stays open.
|
// away (or fade) as they end while the screen stays open.
|
||||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
state.days.mapNotNull { day ->
|
state.days.mapNotNull { day ->
|
||||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
@@ -312,6 +313,14 @@ private fun AgendaContent(
|
|||||||
} else {
|
} else {
|
||||||
state.days
|
state.days
|
||||||
}
|
}
|
||||||
|
// Anchor today with a "nothing left today" placeholder — but only when
|
||||||
|
// the window actually starts on today; a jumped-to date has no today in
|
||||||
|
// it, so anchoring there would be misleading (#35).
|
||||||
|
val days = anchorTodayIfMissing(
|
||||||
|
days = filtered,
|
||||||
|
today = state.today,
|
||||||
|
enabled = showToday && state.anchor == state.today,
|
||||||
|
)
|
||||||
if (days.isEmpty()) {
|
if (days.isEmpty()) {
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
@@ -349,17 +358,24 @@ private fun AgendaList(
|
|||||||
stickyHeader(key = "header-${day.date}") {
|
stickyHeader(key = "header-${day.date}") {
|
||||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
if (day.events.isEmpty()) {
|
||||||
items = day.events,
|
// An anchored, event-less today (#35) — "nothing left today".
|
||||||
key = { _, event -> event.instanceId },
|
item(key = "placeholder-${day.date}") {
|
||||||
) { index, event ->
|
AgendaEmptyDayRow(onClick = { onOpenDay(day.date) })
|
||||||
AgendaEventRow(
|
}
|
||||||
event = event,
|
} else {
|
||||||
position = positionOf(index, day.events.size),
|
itemsIndexed(
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
items = day.events,
|
||||||
modifier = calendarAnimateItem(),
|
key = { _, event -> event.instanceId },
|
||||||
onClick = { onEventClick(event) },
|
) { index, event ->
|
||||||
)
|
AgendaEventRow(
|
||||||
|
event = event,
|
||||||
|
position = positionOf(index, day.events.size),
|
||||||
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
|
modifier = animateItemMotion(),
|
||||||
|
onClick = { onEventClick(event) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||||
}
|
}
|
||||||
@@ -391,6 +407,44 @@ private fun AgendaDayHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card under an anchored, event-less today (#35) — the same coffee-cup motif
|
||||||
|
* as the full-screen [AgendaEmpty] state, boxed into a card so today keeps a
|
||||||
|
* visible slot when nothing is left rather than a bare header.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AgendaEmptyDayRow(onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
// Match a single event row's resting corner radius (floret groupedShape).
|
||||||
|
shape = RoundedCornerShape(22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Coffee,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.agenda_no_more_today),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaEventRow(
|
private fun AgendaEventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
@@ -520,7 +574,7 @@ private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): Str
|
|||||||
private fun formatAgendaDate(date: LocalDate): String {
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ fun groupAgendaDays(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||||
|
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
||||||
|
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
|
||||||
|
* when today already has its own day in [days]. Keeps today anchored at the top
|
||||||
|
* so a glance tells today's events apart from a future day's (issue #35).
|
||||||
|
*/
|
||||||
|
fun anchorTodayIfMissing(
|
||||||
|
days: List<AgendaDay>,
|
||||||
|
today: LocalDate,
|
||||||
|
enabled: Boolean,
|
||||||
|
): List<AgendaDay> =
|
||||||
|
if (enabled && days.none { it.date == today }) {
|
||||||
|
listOf(AgendaDay(today, emptyList())) + days
|
||||||
|
} else {
|
||||||
|
days
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||||
* grouped by day (only days that actually have events appear).
|
* grouped by day (only days that actually have events appear).
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ class AgendaViewModel @Inject constructor(
|
|||||||
initialValue = PastEventDisplay.SHOW,
|
initialValue = PastEventDisplay.SHOW,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to keep today anchored at the top with a "nothing left today"
|
||||||
|
* placeholder even once it has no remaining events (#35). A display concern
|
||||||
|
* applied in the composition (after past-event filtering), so it rides
|
||||||
|
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||||
|
*/
|
||||||
|
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = true,
|
||||||
|
)
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
|
|||||||
@@ -93,22 +93,22 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
@@ -277,6 +277,7 @@ private fun CalendarsList(
|
|||||||
title = stringResource(R.string.calendars_title),
|
title = stringResource(R.string.calendars_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
// What the per-calendar / per-account switches below actually do.
|
// What the per-calendar / per-account switches below actually do.
|
||||||
HintText(stringResource(R.string.calendars_disable_hint))
|
HintText(stringResource(R.string.calendars_disable_hint))
|
||||||
@@ -801,8 +802,8 @@ private fun CalendarGroup(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = expanded,
|
visible = expanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column(content = body)
|
Column(content = body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,22 +13,8 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
/**
|
|
||||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
|
||||||
* - Keeps the hue (so users still recognise their calendars)
|
|
||||||
* - Caps saturation so harsh provider colors stop screaming
|
|
||||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
|
||||||
*/
|
|
||||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
|
||||||
val hsv = FloatArray(3)
|
|
||||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
|
||||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
|
||||||
hsv[2] = if (dark) 0.82f else 0.72f
|
|
||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's single "which calendar" selection list, shared by the event editor
|
* The app's single "which calendar" selection list, shared by the event editor
|
||||||
|
|||||||
@@ -1,56 +1,26 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.BackEventCompat
|
|
||||||
import androidx.activity.compose.PredictiveBackHandler
|
|
||||||
import androidx.compose.animation.ContentTransform
|
import androidx.compose.animation.ContentTransform
|
||||||
import androidx.compose.animation.EnterTransition
|
|
||||||
import androidx.compose.animation.ExitTransition
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.lazy.LazyItemScope
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the user has asked the system to remove animations (Settings →
|
* Calendar-specific motion. The family's generic content transitions — section
|
||||||
* Accessibility → "Remove animations", which sets the global animator duration
|
* expand/collapse, item reveal, list relayout, cross-fade, predictive-back, and
|
||||||
* scale to 0). Compose animations do *not* honour this platform flag on their
|
* the reduce-motion check — now live in floret-kit's identity module
|
||||||
* own, so the shared motion helpers in this file check it and fall back to a
|
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||||
* quick cross-fade — opacity only, no spatial movement — to respect the
|
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||||
* vestibular intent of the setting while keeping state changes legible.
|
* here is only what's specific to paging the calendar grid: the directional
|
||||||
*
|
* month/week/day slide and the specs that feed it.
|
||||||
* Read once at composition; the scale changes rarely and only takes full effect
|
|
||||||
* after a process restart anyway.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
|
||||||
fun rememberReduceMotion(): Boolean {
|
|
||||||
val resolver = LocalContext.current.contentResolver
|
|
||||||
return remember(resolver) {
|
|
||||||
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||||
@@ -98,128 +68,3 @@ fun calendarSlideTransition(
|
|||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return slideInHorizontally(spec) { w -> dir * w }
|
||||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
|
|
||||||
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
|
|
||||||
* opacity, so it doubles as its own reduced-motion form.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarFadeThrough(): ContentTransform {
|
|
||||||
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
|
||||||
return fadeIn(fade).togetherWith(fadeOut(fade))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter transition for a vertically-revealed section (expandable rows, inline
|
|
||||||
* fields): height grows from the top while fading in. Under reduced motion the
|
|
||||||
* height growth is dropped, leaving a quick fade.
|
|
||||||
*
|
|
||||||
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
|
|
||||||
* originally inlined in the event edit form, so every expandable surface in the
|
|
||||||
* app reveals the same way.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
|
||||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
|
||||||
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
|
|
||||||
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
|
|
||||||
* motion keeps the fade only.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
|
||||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
|
||||||
return if (reduceMotion) {
|
|
||||||
fade
|
|
||||||
} else {
|
|
||||||
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
|
|
||||||
* app's motion scheme instead of Compose's default spring. Returns a bare
|
|
||||||
* [Modifier] under reduced motion so rows snap into place. Requires the list to
|
|
||||||
* supply stable item keys.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
|
||||||
if (reduceMotion) {
|
|
||||||
Modifier
|
|
||||||
} else {
|
|
||||||
Modifier.animateItem(
|
|
||||||
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
|
||||||
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
|
||||||
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Standard Android predictive-back transform for a full-screen overlay: as the
|
|
||||||
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
|
||||||
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
|
||||||
* gesture invokes [onBack]; cancelling springs it back.
|
|
||||||
*
|
|
||||||
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
|
||||||
* once and apply the returned [Modifier] to that screen's root so the preview
|
|
||||||
* respects the same back semantics. Under reduced motion the visual preview is
|
|
||||||
* skipped (the back still works); on API < 34 the system delivers no progress,
|
|
||||||
* so it degrades to a plain back.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Modifier.predictiveBack(
|
|
||||||
onBack: () -> Unit,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
reduceMotion: Boolean = rememberReduceMotion(),
|
|
||||||
): Modifier {
|
|
||||||
val progress = remember { Animatable(0f) }
|
|
||||||
var fromLeftEdge by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
PredictiveBackHandler(enabled = enabled) { events ->
|
|
||||||
try {
|
|
||||||
events.collect { event ->
|
|
||||||
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
|
||||||
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
|
||||||
}
|
|
||||||
onBack()
|
|
||||||
progress.snapTo(0f)
|
|
||||||
} catch (_: CancellationException) {
|
|
||||||
progress.animateTo(0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reduceMotion) return this
|
|
||||||
return this.graphicsLayer {
|
|
||||||
val p = progress.value
|
|
||||||
val scale = 1f - 0.1f * p
|
|
||||||
scaleX = scale
|
|
||||||
scaleY = scale
|
|
||||||
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
|
|
||||||
shape = RoundedCornerShape(32.dp.toPx() * p)
|
|
||||||
clip = p > 0f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapping row of round colour swatches; the one matching [selected] is
|
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
|
||||||
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
|
||||||
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
|
||||||
* call site so it never reaches a release build. Non-interactive: it's a plain
|
|
||||||
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
|
||||||
*
|
|
||||||
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
|
||||||
* so it overlays the UI.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun BoxScope.DebugRibbon() {
|
|
||||||
Text(
|
|
||||||
text = "DEBUG",
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
letterSpacing = 1.sp,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.zIndex(1f)
|
|
||||||
// Push the band out so its midline crosses the very corner, then
|
|
||||||
// rotate it to the classic 45° ribbon.
|
|
||||||
.offset(x = 36.dp, y = 24.dp)
|
|
||||||
.rotate(45f)
|
|
||||||
.background(Color(0xFFB23B00))
|
|
||||||
.width(140.dp)
|
|
||||||
.padding(vertical = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
|
|
||||||
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
|
|
||||||
* matches the card/grouped-row design language (not Material's outlined field).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun DialogAmountField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
placeholder: String,
|
|
||||||
) {
|
|
||||||
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
|
|
||||||
// so anything lower vanishes.
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
) {
|
|
||||||
InlineTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = { text ->
|
|
||||||
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
|
|
||||||
},
|
|
||||||
placeholder = placeholder,
|
|
||||||
textStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
modifier = Modifier
|
|
||||||
.width(72.dp)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
|
||||||
@Composable
|
|
||||||
fun DialogUnitDropdown(
|
|
||||||
label: String,
|
|
||||||
entries: List<String>,
|
|
||||||
onPick: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
var open by remember { mutableStateOf(false) }
|
|
||||||
Box {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
onClick = { open = true },
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
|
||||||
) {
|
|
||||||
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
|
||||||
entries.forEachIndexed { index, entry ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(entry) },
|
|
||||||
onClick = {
|
|
||||||
onPick(index)
|
|
||||||
open = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LargeTopAppBar
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Position of a row within a grouped list, after the Android-15 settings
|
|
||||||
* pattern: a run of rows shares one rounded container, with full corners at the
|
|
||||||
* group's outer edges and small corners between, separated by small gaps.
|
|
||||||
*/
|
|
||||||
enum class Position { Top, Middle, Bottom, Alone }
|
|
||||||
|
|
||||||
/** Maps an index within a group of [count] rows to its [Position]. */
|
|
||||||
fun positionOf(index: Int, count: Int): Position = when {
|
|
||||||
count <= 1 -> Position.Alone
|
|
||||||
index == 0 -> Position.Top
|
|
||||||
index == count - 1 -> Position.Bottom
|
|
||||||
else -> Position.Middle
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's standard full-screen list scaffold. By default a collapsing
|
|
||||||
* [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
|
|
||||||
* the content scrolls — used by Settings and the calendar manager, where the
|
|
||||||
* large header sets the page. Content is a scrollable column that feeds the
|
|
||||||
* toolbar via nested scroll.
|
|
||||||
*
|
|
||||||
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
|
|
||||||
* title sits in the bar from the start, with no expanded header to scroll past.
|
|
||||||
* Preferred for selection pickers, where the tall header is just wasted space
|
|
||||||
* above the options.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun CollapsingScaffold(
|
|
||||||
title: String,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
largeTopBar: Boolean = true,
|
|
||||||
snackbarHost: @Composable () -> Unit = {},
|
|
||||||
actions: @Composable RowScope.() -> Unit = {},
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val scrollBehavior = if (largeTopBar) {
|
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
|
||||||
} else {
|
|
||||||
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
|
||||||
}
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier
|
|
||||||
.predictiveBack(onBack = onBack)
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
|
||||||
topBar = {
|
|
||||||
val navigationIcon = @Composable {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.settings_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
)
|
|
||||||
if (largeTopBar) {
|
|
||||||
LargeTopAppBar(
|
|
||||||
title = { Text(title) },
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
actions = actions,
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
colors = colors,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(title) },
|
|
||||||
navigationIcon = navigationIcon,
|
|
||||||
actions = actions,
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
colors = colors,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
snackbarHost = snackbarHost,
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(innerPadding)
|
|
||||||
// Mark the scaffold's system-bar insets as consumed so the
|
|
||||||
// imePadding below adds only the keyboard height beyond them
|
|
||||||
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
|
||||||
// leaves an empty strip above the keyboard.
|
|
||||||
.consumeWindowInsets(innerPadding)
|
|
||||||
.fillMaxSize()
|
|
||||||
// Paint the surface across the full area before imePadding carves
|
|
||||||
// into it, so any sliver above the keyboard reads as surface — not
|
|
||||||
// the dialog window's black — during the IME animation.
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
// Shrink the scroll viewport by the keyboard inset so a focused
|
|
||||||
// field (e.g. the custom-reminder amount) can scroll into view.
|
|
||||||
.imePadding()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(top = 8.dp, bottom = 24.dp),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
|
||||||
* corner radii come from its [position] (so a run of rows reads as a single
|
|
||||||
* rounded card). Corners round further on press. A null [onClick] makes the
|
|
||||||
* row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
|
|
||||||
* summary to the M3 disabled emphasis while leaving the [trailing] control at
|
|
||||||
* full opacity — for rows that are present but switched off.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun GroupedRow(
|
|
||||||
title: String,
|
|
||||||
position: Position,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
summary: String? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
dimmed: Boolean = false,
|
|
||||||
container: Color? = null,
|
|
||||||
minHeight: Dp = 72.dp,
|
|
||||||
// The 2.dp separation between rows in a run. Suppressed by the reorderable
|
|
||||||
// list, which owns uniform spacing itself so every slot has the same pitch.
|
|
||||||
gapBelow: Boolean = true,
|
|
||||||
leading: @Composable (() -> Unit)? = null,
|
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val interaction = remember { MutableInteractionSource() }
|
|
||||||
val pressed by interaction.collectIsPressedAsState()
|
|
||||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
|
||||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
|
||||||
val shape = when (position) {
|
|
||||||
Position.Alone -> RoundedCornerShape(full)
|
|
||||||
Position.Top -> RoundedCornerShape(
|
|
||||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
|
||||||
)
|
|
||||||
Position.Middle -> RoundedCornerShape(small)
|
|
||||||
Position.Bottom -> RoundedCornerShape(
|
|
||||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val gap = when {
|
|
||||||
!gapBelow -> Modifier
|
|
||||||
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
|
||||||
else -> Modifier
|
|
||||||
}
|
|
||||||
val itemColors = if (selected) {
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
} else if (dimmed) {
|
|
||||||
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
|
|
||||||
// stays full-opacity so the toggle reads as live even on a faded row.
|
|
||||||
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
headlineColor = muted,
|
|
||||||
leadingIconColor = muted,
|
|
||||||
supportingColor = muted,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
|
||||||
}
|
|
||||||
val item: @Composable () -> Unit = {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(title) },
|
|
||||||
supportingContent = summary?.let { text -> { Text(text) } },
|
|
||||||
leadingContent = leading,
|
|
||||||
trailingContent = trailing,
|
|
||||||
colors = itemColors,
|
|
||||||
modifier = Modifier.heightIn(min = minHeight),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val base = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.then(gap)
|
|
||||||
val containerColor = when {
|
|
||||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
container != null -> container
|
|
||||||
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
}
|
|
||||||
if (onClick != null) {
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
color = containerColor,
|
|
||||||
shape = shape,
|
|
||||||
interactionSource = interaction,
|
|
||||||
modifier = base,
|
|
||||||
) { item() }
|
|
||||||
} else {
|
|
||||||
Surface(color = containerColor, shape = shape, modifier = base) { item() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's borderless text input: no underline, no outline, just the tonal
|
|
||||||
* card behind it. This is the standard input across the app — we deliberately
|
|
||||||
* don't use Material's outlined/filled text fields, so anything that takes text
|
|
||||||
* (the event form, the calendar manager, dialogs) uses this inside a tonal
|
|
||||||
* [androidx.compose.material3.Surface].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun InlineTextField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
placeholder: String,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
|
||||||
singleLine: Boolean = true,
|
|
||||||
minLines: Int = 1,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
|
||||||
imeAction: ImeAction = ImeAction.Default,
|
|
||||||
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
|
||||||
onImeAction: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val resolvedStyle = textStyle.copy(
|
|
||||||
color = when {
|
|
||||||
// A disabled field reads as dimmed, like a locked value.
|
|
||||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
textStyle.color.isSpecified -> textStyle.color
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
|
||||||
},
|
|
||||||
)
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
enabled = enabled,
|
|
||||||
textStyle = resolvedStyle,
|
|
||||||
singleLine = singleLine,
|
|
||||||
minLines = minLines,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = keyboardType,
|
|
||||||
capitalization = capitalization,
|
|
||||||
imeAction = imeAction,
|
|
||||||
),
|
|
||||||
keyboardActions = onImeAction?.let { action ->
|
|
||||||
KeyboardActions(onAny = { action() })
|
|
||||||
} ?: KeyboardActions.Default,
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box {
|
|
||||||
if (value.isEmpty()) {
|
|
||||||
// Clearly fainter than typed text, so a hint never reads as
|
|
||||||
// prefilled content.
|
|
||||||
Text(
|
|
||||||
text = placeholder,
|
|
||||||
style = resolvedStyle,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.core.os.ConfigurationCompat
|
import androidx.core.os.ConfigurationCompat
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,3 +19,15 @@ fun currentLocale(): Locale {
|
|||||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order
|
||||||
|
* (via Android's best-pattern matching), so dates read naturally per locale
|
||||||
|
* instead of a hardcoded day-month-year order. [skeleton] lists the wanted
|
||||||
|
* fields, e.g. "dMMMy" (day, abbreviated month, year) or "EEEdMMM" (weekday too).
|
||||||
|
*/
|
||||||
|
fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern(
|
||||||
|
android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton),
|
||||||
|
locale,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's standard pick in a selection dialog: a full-width tonal card,
|
|
||||||
* optionally with a leading icon and a supporting line; the selected option
|
|
||||||
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
|
|
||||||
* only sanctioned selection-modal style (no radio rows, no bare text lists).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun OptionCard(
|
|
||||||
label: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
icon: ImageVector? = null,
|
|
||||||
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
|
|
||||||
iconTint: Color = Color.Unspecified,
|
|
||||||
supportingText: String? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
|
|
||||||
labelColor: Color = Color.Unspecified,
|
|
||||||
) {
|
|
||||||
val contentColor = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
color = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
|
||||||
) {
|
|
||||||
if (icon != null) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = when {
|
|
||||||
iconTint.isSpecified -> iconTint
|
|
||||||
selected -> MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = if (labelColor.isSpecified) labelColor else contentColor,
|
|
||||||
)
|
|
||||||
if (supportingText != null) {
|
|
||||||
Text(
|
|
||||||
text = supportingText,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +1,39 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SegmentedButton
|
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import android.view.WindowManager
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
import androidx.compose.ui.window.DialogWindowProvider
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
|
||||||
* reuses the app's [CollapsingScaffold] (back button + full width), but with a
|
|
||||||
* pinned single-line title rather than the large collapsing header — a picker is
|
|
||||||
* a short list, so the tall header would only be empty space to scroll past.
|
|
||||||
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun FullScreenPicker(
|
|
||||||
title: String,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
properties = DialogProperties(
|
|
||||||
usePlatformDefaultWidth = false,
|
|
||||||
decorFitsSystemWindows = false,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
// The dialog window pans by default when the keyboard opens, which —
|
|
||||||
// combined with the content's own imePadding — leaves a fixed black gap
|
|
||||||
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
|
||||||
// full-screen and imePadding alone lifts the focused field.
|
|
||||||
val view = LocalView.current
|
|
||||||
SideEffect {
|
|
||||||
(view.parent as? DialogWindowProvider)?.window
|
|
||||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
|
||||||
}
|
|
||||||
CollapsingScaffold(
|
|
||||||
title = title,
|
|
||||||
onBack = onDismiss,
|
|
||||||
largeTopBar = false,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* General single-select picker, full-screen: each option is a connected grouped
|
|
||||||
* row and the current one carries a check. Drop-in for the former dialog
|
|
||||||
* (theme, week start, language, …).
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun <T> OptionPicker(
|
|
||||||
title: String,
|
|
||||||
options: List<T>,
|
|
||||||
selected: T,
|
|
||||||
label: @Composable (T) -> String,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
|
||||||
header?.invoke(this)
|
|
||||||
options.forEachIndexed { index, option ->
|
|
||||||
val isSelected = option == selected
|
|
||||||
GroupedRow(
|
|
||||||
title = label(option),
|
|
||||||
position = positionOf(index, options.size),
|
|
||||||
selected = isSelected,
|
|
||||||
trailing = if (isSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
onSelect(option)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||||
@@ -128,7 +43,7 @@ fun <T> OptionPicker(
|
|||||||
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||||
* defers to the global default. Clearing the last checked time reverts to "Use
|
* defers to the global default. Clearing the last checked time reverts to "Use
|
||||||
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
||||||
* wipe the calendar's default) and to explicit [CalendarReminderOverride.None]
|
* wipe the calendar's default) and to explicit [ReminderOverride.None]
|
||||||
* on the global default (where empty legitimately means no reminder). A "Custom"
|
* on the global default (where empty legitimately means no reminder). A "Custom"
|
||||||
* row expands an inline number field plus a unit selector to add an arbitrary
|
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||||
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||||
@@ -138,9 +53,9 @@ fun <T> OptionPicker(
|
|||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
title: String,
|
title: String,
|
||||||
presets: List<Int>,
|
presets: List<Int>,
|
||||||
selected: CalendarReminderOverride,
|
selected: ReminderOverride,
|
||||||
allowInherit: Boolean,
|
allowInherit: Boolean,
|
||||||
onSelect: (CalendarReminderOverride) -> Unit,
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Optimistic local state: once the user edits, the chosen override is
|
// Optimistic local state: once the user edits, the chosen override is
|
||||||
@@ -158,9 +73,9 @@ fun ReminderDefaultPicker(
|
|||||||
LaunchedEffect(selected) {
|
LaunchedEffect(selected) {
|
||||||
if (!userEdited) current = selected
|
if (!userEdited) current = selected
|
||||||
}
|
}
|
||||||
val inherits = current is CalendarReminderOverride.Inherit
|
val inherits = current is ReminderOverride.Inherit
|
||||||
val isNone = current is CalendarReminderOverride.None
|
val isNone = current is ReminderOverride.None
|
||||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||||
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
||||||
// which would strand a hand-entered value with no way to re-check it.
|
// which would strand a hand-entered value with no way to re-check it.
|
||||||
@@ -173,7 +88,7 @@ fun ReminderDefaultPicker(
|
|||||||
var amountText by rememberSaveable { mutableStateOf("") }
|
var amountText by rememberSaveable { mutableStateOf("") }
|
||||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||||
|
|
||||||
fun apply(override: CalendarReminderOverride) {
|
fun apply(override: ReminderOverride) {
|
||||||
userEdited = true
|
userEdited = true
|
||||||
current = override
|
current = override
|
||||||
onSelect(override)
|
onSelect(override)
|
||||||
@@ -197,7 +112,7 @@ fun ReminderDefaultPicker(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
onClick = { apply(ReminderOverride.Inherit) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -209,7 +124,7 @@ fun ReminderDefaultPicker(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = { apply(CalendarReminderOverride.None) },
|
onClick = { apply(ReminderOverride.None) },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
val rowCount = rows.size + 1 // + the custom row
|
val rowCount = rows.size + 1 // + the custom row
|
||||||
@@ -232,8 +147,8 @@ fun ReminderDefaultPicker(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomReminderEditor(
|
CustomReminderEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -250,26 +165,6 @@ fun ReminderDefaultPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The override a lead-time set maps to when emitted from [ReminderDefaultPicker].
|
|
||||||
* A non-empty set is [CalendarReminderOverride.Minutes]; an empty set (the last
|
|
||||||
* time was unchecked) reverts to [CalendarReminderOverride.Inherit] on a
|
|
||||||
* per-calendar picker ([allowInherit]) so an accidental toggle-undo can't
|
|
||||||
* silently wipe the calendar's default, and to explicit
|
|
||||||
* [CalendarReminderOverride.None] on the global default (where empty legitimately
|
|
||||||
* means no reminder). Pure so it can be unit-tested.
|
|
||||||
*/
|
|
||||||
internal fun reminderOverrideForMinutes(
|
|
||||||
minutes: List<Int>,
|
|
||||||
allowInherit: Boolean,
|
|
||||||
): CalendarReminderOverride {
|
|
||||||
val norm = minutes.distinct().sorted()
|
|
||||||
return when {
|
|
||||||
norm.isNotEmpty() -> CalendarReminderOverride.Minutes(norm)
|
|
||||||
allowInherit -> CalendarReminderOverride.Inherit
|
|
||||||
else -> CalendarReminderOverride.None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||||
@@ -287,56 +182,18 @@ private fun CustomReminderEditor(
|
|||||||
onConfirm: (Int) -> Unit,
|
onConfirm: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
Surface(
|
CustomAmountEditor(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
amountText = amountText,
|
||||||
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
onAmountChange = onAmountChange,
|
||||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||||
modifier = Modifier
|
selectedUnit = unit.ordinal,
|
||||||
.fillMaxWidth()
|
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
|
||||||
.padding(horizontal = 16.dp),
|
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
) {
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
Column(
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
modifier = Modifier.padding(16.dp),
|
confirmEnabled = amount != null,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
) {
|
)
|
||||||
// Unit toggle first so it stays visible above the keyboard once the
|
|
||||||
// amount field (the bottom row) is focused and scrolled into view.
|
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
ReminderUnit.entries.forEachIndexed { index, entry ->
|
|
||||||
SegmentedButton(
|
|
||||||
selected = unit == entry,
|
|
||||||
onClick = { onUnitChange(entry) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
|
||||||
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Amount, a live preview of the lead time it resolves to, and Set —
|
|
||||||
// all on one row, sitting just above the keyboard.
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
DialogAmountField(
|
|
||||||
value = amountText,
|
|
||||||
onValueChange = onAmountChange,
|
|
||||||
placeholder = "10",
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Text(
|
|
||||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
|
||||||
?: stringResource(R.string.reminder_custom_amount),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
|
||||||
enabled = amount != null,
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.reminder_custom_set))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||||
@@ -432,8 +289,8 @@ fun AgendaRangePicker(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomDaysEditor(
|
CustomDaysEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -456,41 +313,136 @@ private fun CustomDaysEditor(
|
|||||||
) {
|
) {
|
||||||
val days = amountText.toIntOrNull()
|
val days = amountText.toIntOrNull()
|
||||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||||
Surface(
|
CustomAmountEditor(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
amountText = amountText,
|
||||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
onAmountChange = onAmountChange,
|
||||||
modifier = Modifier
|
placeholder = "30",
|
||||||
.fillMaxWidth()
|
preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||||
.padding(horizontal = 16.dp),
|
?: stringResource(R.string.agenda_range_custom_hint),
|
||||||
) {
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
Row(
|
confirmEnabled = days != null,
|
||||||
modifier = Modifier.padding(16.dp),
|
onConfirm = { days?.let(onConfirm) },
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
||||||
|
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
||||||
|
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
||||||
|
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
||||||
|
* a preset or confirming a custom value applies via [onSelect] and closes.
|
||||||
|
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
||||||
|
* is reused for both the rows and the custom preview.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SnoozeDurationPicker(
|
||||||
|
title: String,
|
||||||
|
presets: List<Int>,
|
||||||
|
selected: Int,
|
||||||
|
label: @Composable (Int) -> String,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val customSelected = selected !in presets
|
||||||
|
val rowCount = presets.size + 1 // + the custom row
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable {
|
||||||
|
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
||||||
|
}
|
||||||
|
var unit by rememberSaveable {
|
||||||
|
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||||
|
presets.forEachIndexed { index, minute ->
|
||||||
|
val isSelected = minute == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = label(minute),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(minute)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The Custom row connects downward into the editor card when expanded, so
|
||||||
|
// the two read as one grouped container (the shared custom-expand pattern).
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
trailing = if (customSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = customExpanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
DialogAmountField(
|
CustomSnoozeEditor(
|
||||||
value = amountText,
|
amountText = amountText,
|
||||||
onValueChange = onAmountChange,
|
onAmountChange = { amountText = it },
|
||||||
placeholder = "30",
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
label = label,
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(minutes)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whole hours if the delay divides evenly, else minutes. */
|
||||||
|
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
||||||
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||||
|
|
||||||
|
private fun snoozeCustomAmount(minutes: Int): Int =
|
||||||
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
||||||
|
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||||
|
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||||
|
* amount. [onConfirm] receives the final delay in minutes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CustomSnoozeEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
unit: ReminderUnit,
|
||||||
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
|
label: @Composable (Int) -> String,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
||||||
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
CustomAmountEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = onAmountChange,
|
||||||
|
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||||
|
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
||||||
|
onUnitChange = { onUnitChange(units[it]) },
|
||||||
|
preview = amount?.let { label(it * unit.minutesFactor) }
|
||||||
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
|
confirmEnabled = amount != null,
|
||||||
|
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||||
@Composable
|
@Composable
|
||||||
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||||
|
|||||||
@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the form and settings. */
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
|
||||||
enum class ReminderUnit(val minutesFactor: Int) {
|
|
||||||
Minutes(1),
|
|
||||||
Hours(60),
|
|
||||||
Days(1_440),
|
|
||||||
Weeks(10_080),
|
|
||||||
}
|
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.Spring
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.snap
|
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
|
||||||
val ReorderableRowHeight: Dp = 64.dp
|
|
||||||
private val RowGap: Dp = 2.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A vertical list whose rows can be dragged into a new order by their handle.
|
|
||||||
*
|
|
||||||
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
|
|
||||||
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
|
|
||||||
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
|
|
||||||
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
|
||||||
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
|
||||||
* been dragged. The held row follows the finger while the others slide out of
|
|
||||||
* its way (animated); on release the order is committed immediately with a
|
|
||||||
* single [onReorder] call, then the held row eases into its new slot as a
|
|
||||||
* purely visual settle — safe to interrupt with another drag, since the
|
|
||||||
* commit itself never waits on it.
|
|
||||||
*
|
|
||||||
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
|
||||||
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
|
||||||
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun <T> ReorderableColumn(
|
|
||||||
items: List<T>,
|
|
||||||
keyOf: (T) -> Any,
|
|
||||||
onReorder: (List<T>) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
// Local working copy; re-seeded when the incoming list changes (including the
|
|
||||||
// echo of our own committed order).
|
|
||||||
var order by remember(items) { mutableStateOf(items) }
|
|
||||||
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
|
||||||
// Live translation of the held row from its slot (px); also drives the
|
|
||||||
// release settle — re-based onto the row's new slot once the order commits
|
|
||||||
// (see onDragEnd), then eased down to zero.
|
|
||||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
|
||||||
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
|
||||||
// Purely visual — the order commit (see onDragEnd) never depends on it.
|
|
||||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
|
||||||
|
|
||||||
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
|
||||||
// Whole slots dragged → the slot the held row currently hovers over. Derived
|
|
||||||
// so recomposition only fires when the *target slot* actually changes, not on
|
|
||||||
// every dragged pixel: dragOffset moves every frame during a drag, but the
|
|
||||||
// held row's own translation is already applied in the draw phase via
|
|
||||||
// graphicsLayer below, so only the neighbours' shift (which depends on this)
|
|
||||||
// needs to recompose, and only when a slot boundary is actually crossed.
|
|
||||||
// (Read as a plain val, not `by`, below: a delegated property has a custom
|
|
||||||
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
|
|
||||||
// the loop, so the derived value is captured into a real local instead.)
|
|
||||||
val targetIndex = remember(items, pitchPx) {
|
|
||||||
derivedStateOf {
|
|
||||||
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
|
||||||
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
|
|
||||||
}
|
|
||||||
}.value
|
|
||||||
|
|
||||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
|
||||||
order.forEachIndexed { index, item ->
|
|
||||||
val key = keyOf(item)
|
|
||||||
val isDragged = key == draggedKey
|
|
||||||
|
|
||||||
// Slide neighbours by one pitch to open the gap the held row will drop
|
|
||||||
// into. Snap (not animate) once idle, so committing the new order — which
|
|
||||||
// moves each row's slot — doesn't visibly fight a lingering animation.
|
|
||||||
val shift = when {
|
|
||||||
draggedIndex == null || targetIndex == null || isDragged -> 0f
|
|
||||||
index in (draggedIndex + 1)..targetIndex -> -pitchPx
|
|
||||||
index in targetIndex until draggedIndex -> pitchPx
|
|
||||||
else -> 0f
|
|
||||||
}
|
|
||||||
val animatedShift by animateFloatAsState(
|
|
||||||
targetValue = shift,
|
|
||||||
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
|
|
||||||
label = "reorderShift",
|
|
||||||
)
|
|
||||||
|
|
||||||
val dragHandle = Modifier.pointerInput(key) {
|
|
||||||
detectDragGestures(
|
|
||||||
onDragStart = {
|
|
||||||
settleJob?.cancel()
|
|
||||||
draggedKey = key
|
|
||||||
dragOffset = 0f
|
|
||||||
},
|
|
||||||
onDrag = { change, amount ->
|
|
||||||
change.consume()
|
|
||||||
dragOffset += amount.y
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
val from = order.indexOfFirst { keyOf(it) == key }
|
|
||||||
if (from < 0) return@detectDragGestures
|
|
||||||
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
|
||||||
if (to != from) {
|
|
||||||
// Commit synchronously and unconditionally, before the settle
|
|
||||||
// animation below runs — the commit must not depend on that
|
|
||||||
// coroutine reaching its end, or a new drag starting within the
|
|
||||||
// ~160ms settle window would cancel it and silently revert an
|
|
||||||
// already-finished reorder.
|
|
||||||
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
|
||||||
onReorder(order)
|
|
||||||
// The row now lays out at slot `to` instead of `from`; re-base
|
|
||||||
// the live offset onto that new slot (same visual position,
|
|
||||||
// expressed relative to the new one) so the settle below eases
|
|
||||||
// it the rest of the way instead of jumping.
|
|
||||||
dragOffset -= (to - from) * pitchPx
|
|
||||||
}
|
|
||||||
settleJob = scope.launch {
|
|
||||||
// Purely visual from here: ease the held row onto its slot, then
|
|
||||||
// release the drag state. Safe to cancel — the order was already
|
|
||||||
// committed above.
|
|
||||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
|
||||||
draggedKey = null
|
|
||||||
dragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDragCancel = {
|
|
||||||
settleJob = scope.launch {
|
|
||||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
|
||||||
draggedKey = null
|
|
||||||
dragOffset = 0f
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.height(ReorderableRowHeight)
|
|
||||||
.zIndex(if (isDragged) 1f else 0f)
|
|
||||||
.graphicsLayer {
|
|
||||||
translationY = if (isDragged) dragOffset else animatedShift
|
|
||||||
if (isDragged) {
|
|
||||||
scaleX = 1.02f
|
|
||||||
scaleY = 1.02f
|
|
||||||
shadowElevation = 8.dp.toPx()
|
|
||||||
shape = RoundedCornerShape(20.dp)
|
|
||||||
clip = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A deliberately minimal, standalone surface for a captured crash report.
|
* A deliberately minimal, standalone surface for a captured crash report.
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.crash
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.BugReport
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asks the user to send a captured crash report as an issue. The full report is
|
|
||||||
* shown verbatim in a scrollable panel — the user sees exactly what will leave
|
|
||||||
* the device before choosing to share it (the privacy backstop). [onSend] hands
|
|
||||||
* off to [submitCrashReport]; [onDismiss] declines.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CrashReportDialog(
|
|
||||||
report: String,
|
|
||||||
onSend: () -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
|
|
||||||
title = { Text(stringResource(R.string.crash_dialog_title)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.crash_dialog_message),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = report,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.heightIn(max = 220.dp)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.crash
|
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.ClipboardManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hand the captured crash report off to the user's chosen channel: the report
|
|
||||||
* is copied to the clipboard (the reliable path for a full stack trace) and the
|
|
||||||
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
|
|
||||||
* sent automatically — the app has no network access; the user reviews and
|
|
||||||
* submits the issue themselves.
|
|
||||||
*/
|
|
||||||
fun submitCrashReport(context: Context, report: String) {
|
|
||||||
copyReportToClipboard(context, report)
|
|
||||||
val opened = runCatching {
|
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
|
|
||||||
}.isSuccess
|
|
||||||
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
|
|
||||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
|
|
||||||
fun openIssueTracker(context: Context) {
|
|
||||||
val uri = context.getString(R.string.report_issue_choose_url).toUri()
|
|
||||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun copyReportToClipboard(context: Context, report: String) {
|
|
||||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
|
||||||
val label = context.getString(R.string.crash_report_clip_label)
|
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
|
|
||||||
* can blow past URL-length limits, so an over-long one is left out of the link
|
|
||||||
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
|
|
||||||
* source of truth in that case.
|
|
||||||
*/
|
|
||||||
private fun buildIssueUri(context: Context, report: String) =
|
|
||||||
context.getString(R.string.report_issue_url).toUri().buildUpon()
|
|
||||||
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
|
|
||||||
.appendQueryParameter("body", buildIssueBody(context, report))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun buildIssueBody(context: Context, report: String): String {
|
|
||||||
val block = if (report.length > MAX_URL_REPORT_CHARS) {
|
|
||||||
context.getString(R.string.crash_report_body_paste)
|
|
||||||
} else {
|
|
||||||
"```\n$report\n```"
|
|
||||||
}
|
|
||||||
return context.getString(R.string.crash_report_body_template, block)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep the prefilled body comfortably under common URL-length ceilings. */
|
|
||||||
private const val MAX_URL_REPORT_CHARS = 6_000
|
|
||||||
@@ -77,9 +77,9 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -97,6 +97,10 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
private val HOUR_HEIGHT = 56.dp
|
||||||
private val GUTTER_WIDTH = 48.dp
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||||
|
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||||
|
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||||
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
private val MIN_EVENT_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
@@ -484,10 +488,12 @@ private fun Timeline(
|
|||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position.
|
// soft corners are permanent at any scroll position.
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
// Hour gutter (scrolls in sync with the day column)
|
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||||
|
// labels centre on the top bar hamburger, matching the week view.
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(GUTTER_WIDTH)
|
.width(GUTTER_WIDTH)
|
||||||
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
@@ -87,17 +88,18 @@ import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
|||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -116,7 +118,10 @@ import kotlin.time.Instant
|
|||||||
* top-bar arrow both return to the calendar. Events in writable calendars can
|
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||||
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
||||||
* event form for this occurrence — for recurring events the form asks how
|
* event form for this occurrence — for recurring events the form asks how
|
||||||
* far the change reaches when saving.
|
* far the change reaches when saving. [onDuplicate] opens the same form
|
||||||
|
* seeded with a copy of this event as a new, unsaved event (#52) — offered
|
||||||
|
* for any event, including read-only ones, since the copy lands in an
|
||||||
|
* editable calendar.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -126,6 +131,7 @@ fun EventDetailScreen(
|
|||||||
endMillis: Long,
|
endMillis: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
|
onDuplicate: (EventForm) -> Unit,
|
||||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
@@ -160,15 +166,14 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the tapped action.
|
// upgrade in place. Granting continues straight into the tapped action —
|
||||||
var pendingEdit by remember { mutableStateOf(false) }
|
// edit, delete, or duplicate — held here until the result comes back.
|
||||||
|
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
if (granted) {
|
if (granted) pendingWrite?.invoke()
|
||||||
if (pendingEdit) onEdit() else showDeleteDialog = true
|
pendingWrite = null
|
||||||
}
|
|
||||||
pendingEdit = false
|
|
||||||
}
|
}
|
||||||
val hasWritePermission = {
|
val hasWritePermission = {
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
@@ -176,21 +181,20 @@ fun EventDetailScreen(
|
|||||||
Manifest.permission.WRITE_CALENDAR,
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
val onDeleteClick = {
|
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||||
if (hasWritePermission()) {
|
if (hasWritePermission()) {
|
||||||
showDeleteDialog = true
|
action()
|
||||||
} else {
|
} else {
|
||||||
pendingEdit = false
|
pendingWrite = action
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onEditClick = {
|
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||||
if (hasWritePermission()) {
|
val onEditClick = { requireWrite(onEdit) }
|
||||||
onEdit()
|
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||||
} else {
|
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||||
pendingEdit = true
|
val onDuplicateClick = {
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||||
@@ -229,7 +233,8 @@ fun EventDetailScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
val s = state
|
val s = state
|
||||||
// Share works for any loaded event — it only reads the event.
|
// Share and duplicate work for any loaded event — both only
|
||||||
|
// read it; the duplicate is created into a writable calendar.
|
||||||
if (s is EventDetailUiState.Success) {
|
if (s is EventDetailUiState.Success) {
|
||||||
IconButton(onClick = onShareClick) {
|
IconButton(onClick = onShareClick) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -237,6 +242,15 @@ fun EventDetailScreen(
|
|||||||
contentDescription = stringResource(R.string.event_detail_share),
|
contentDescription = stringResource(R.string.event_detail_share),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onDuplicateClick,
|
||||||
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_duplicate),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Edit/delete need a writable calendar — WebCal subscriptions,
|
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||||
// birthday calendars etc. are read-only at the provider level.
|
// birthday calendars etc. are read-only at the provider level.
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -138,16 +141,48 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a create form seeded from the open event so the user can save a
|
||||||
|
* copy as a new, independent event (#52). Returns null when nothing is
|
||||||
|
* loaded.
|
||||||
|
*
|
||||||
|
* The recurrence is dropped — a duplicate is a single event; anyone who
|
||||||
|
* wants a series can re-add one in the edit form that opens. The source
|
||||||
|
* calendar is kept only when it's writable, otherwise the id is cleared so
|
||||||
|
* the create resolves to the last-used/first-writable calendar — which lets
|
||||||
|
* a read-only event (a WebCal subscription, a birthday) be copied into an
|
||||||
|
* editable calendar. The occurrence's own times carry over unchanged.
|
||||||
|
*/
|
||||||
|
fun duplicateForm(): EventForm? {
|
||||||
|
val loaded = state.value as? EventDetailUiState.Success ?: return null
|
||||||
|
val detail = loaded.detail
|
||||||
|
return detail.toEditForm(
|
||||||
|
beginMillis = detail.instance.start.toEpochMilliseconds(),
|
||||||
|
endMillis = detail.instance.end.toEpochMilliseconds(),
|
||||||
|
zone = TimeZone.currentSystemDefault(),
|
||||||
|
).copy(
|
||||||
|
rrule = null,
|
||||||
|
calendarId = detail.instance.calendarId.takeIf { loaded.canModify },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
|
||||||
val detail = repository.eventDetail(target.eventId)
|
val detail = repository.eventDetail(target.eventId)
|
||||||
// The Events row holds the series start; replace it with this
|
// The Events row holds the series start; replace it with this
|
||||||
// occurrence's time so recurring events render correctly.
|
// occurrence's time so recurring events render correctly. An external
|
||||||
val corrected = detail.copy(
|
// "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a
|
||||||
instance = detail.instance.copy(
|
// bare content://.../events/<id> VIEW intent, issue #48) keeps the row's
|
||||||
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
// own DTSTART/DTEND instead of overriding it to the epoch.
|
||||||
end = Instant.fromEpochMilliseconds(target.endMillis),
|
val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) {
|
||||||
),
|
detail
|
||||||
)
|
} else {
|
||||||
|
detail.copy(
|
||||||
|
instance = detail.instance.copy(
|
||||||
|
start = Instant.fromEpochMilliseconds(target.beginMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(target.endMillis),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
val calendar = repository.calendars().first()
|
val calendar = repository.calendars().first()
|
||||||
.firstOrNull { it.id == corrected.instance.calendarId }
|
.firstOrNull { it.id == corrected.instance.calendarId }
|
||||||
EventDetailUiState.Success(
|
EventDetailUiState.Success(
|
||||||
@@ -168,6 +203,16 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
/** A tapped occurrence: the series [eventId] plus this occurrence's own times. */
|
||||||
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Sentinel begin/end for an "open this event" that names no occurrence —
|
||||||
|
* a bare `content://com.android.calendar/events/<id>` VIEW intent with no
|
||||||
|
* `EXTRA_EVENT_BEGIN_TIME` (issue #48). [loadDetail] then keeps the event
|
||||||
|
* row's own DTSTART/DTEND instead of overriding it to the epoch.
|
||||||
|
*/
|
||||||
|
const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
/** A filesystem-safe `.ics` file name from an event title (or a fallback). */
|
||||||
|
|||||||
@@ -89,12 +89,14 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -116,33 +118,35 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -179,12 +183,14 @@ fun EventEditScreen(
|
|||||||
editKey: LongArray? = null,
|
editKey: LongArray? = null,
|
||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
|
initialFormSource: ImportSource = ImportSource.File,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
when {
|
when {
|
||||||
// Single-event .ics open: the form arrives prefilled for review.
|
// A prefilled open: a single-event .ics for review, or an external
|
||||||
initialForm != null -> viewModel.openImported(initialForm)
|
// ACTION_INSERT intent. The source drives how reminders are seeded.
|
||||||
|
initialForm != null -> viewModel.openImported(initialForm, initialFormSource)
|
||||||
editKey != null -> viewModel.openForEdit(
|
editKey != null -> viewModel.openForEdit(
|
||||||
eventId = editKey[0],
|
eventId = editKey[0],
|
||||||
beginMillis = editKey[1],
|
beginMillis = editKey[1],
|
||||||
@@ -199,6 +205,7 @@ fun EventEditScreen(
|
|||||||
}
|
}
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
val loadFailed by viewModel.loadFailed.collectAsStateWithLifecycle()
|
||||||
|
val importReminderPrompt by viewModel.importReminderPrompt.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// The form is intentionally forgotten on every close (cancel or save) so
|
// The form is intentionally forgotten on every close (cancel or save) so
|
||||||
// the next open starts clean; it survives rotation because openNew /
|
// the next open starts clean; it survives rotation because openNew /
|
||||||
@@ -329,6 +336,56 @@ fun EventEditScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A .ics import respects the file's reminders, but offers to swap in the
|
||||||
|
// configured default rather than silently deciding for the user (#49).
|
||||||
|
importReminderPrompt?.let { prompt ->
|
||||||
|
ImportReminderPromptDialog(
|
||||||
|
currentReminderCount = prompt.currentReminderCount,
|
||||||
|
onApply = viewModel::applyImportedReminderDefault,
|
||||||
|
onKeep = viewModel::dismissImportedReminderPrompt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer to apply the settings default reminder to an event opened from a `.ics`
|
||||||
|
* file. The file's own reminders are kept unless the user accepts. A plain
|
||||||
|
* two-choice confirmation, so an [AlertDialog] (not a full-screen picker).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ImportReminderPromptDialog(
|
||||||
|
currentReminderCount: Int,
|
||||||
|
onApply: () -> Unit,
|
||||||
|
onKeep: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onKeep,
|
||||||
|
title = { Text(stringResource(R.string.import_reminder_prompt_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
if (currentReminderCount == 0) {
|
||||||
|
stringResource(R.string.import_reminder_prompt_body_none)
|
||||||
|
} else {
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.import_reminder_prompt_body_existing,
|
||||||
|
currentReminderCount,
|
||||||
|
currentReminderCount,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onApply) {
|
||||||
|
Text(stringResource(R.string.import_reminder_prompt_apply))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onKeep) {
|
||||||
|
Text(stringResource(R.string.import_reminder_prompt_keep))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -343,29 +400,29 @@ private fun SaveConflictDialog(
|
|||||||
onDiscard: () -> Unit,
|
onDiscard: () -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
FullScreenPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_conflict_title),
|
||||||
title = { Text(stringResource(R.string.event_edit_conflict_title)) },
|
onDismiss = onDismiss,
|
||||||
text = {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Text(
|
||||||
Text(stringResource(R.string.event_edit_conflict_body))
|
text = stringResource(R.string.event_edit_conflict_body),
|
||||||
Spacer(Modifier.height(4.dp))
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
OptionCard(
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
label = stringResource(R.string.event_edit_conflict_overwrite),
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
supportingText = stringResource(R.string.event_edit_conflict_overwrite_hint),
|
)
|
||||||
onClick = onOverwrite,
|
GroupedRow(
|
||||||
)
|
title = stringResource(R.string.event_edit_conflict_overwrite),
|
||||||
OptionCard(
|
summary = stringResource(R.string.event_edit_conflict_overwrite_hint),
|
||||||
label = stringResource(R.string.event_edit_conflict_discard),
|
position = positionOf(0, 2),
|
||||||
supportingText = stringResource(R.string.event_edit_conflict_discard_hint),
|
onClick = onOverwrite,
|
||||||
onClick = onDiscard,
|
)
|
||||||
)
|
GroupedRow(
|
||||||
}
|
title = stringResource(R.string.event_edit_conflict_discard),
|
||||||
},
|
summary = stringResource(R.string.event_edit_conflict_discard_hint),
|
||||||
confirmButton = {
|
position = positionOf(1, 2),
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
onClick = onDiscard,
|
||||||
},
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -421,8 +478,8 @@ private fun OptionalFormSection(
|
|||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
||||||
}
|
}
|
||||||
@@ -519,16 +576,23 @@ private fun EventEditContent(
|
|||||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
) {
|
) {
|
||||||
// Title: borderless headline, mirroring the detail screen's title +
|
// Title: borderless headline, mirroring the detail screen's title +
|
||||||
// accent bar instead of a boxed Material text field.
|
// accent bar instead of a boxed Material text field. Multi-line so long
|
||||||
|
// titles wrap instead of scrolling off one line (#33); newlines are
|
||||||
|
// stripped in setTitle, so this stays a single logical line — the Enter
|
||||||
|
// key just has no effect.
|
||||||
InlineField(
|
InlineField(
|
||||||
value = form.title,
|
value = form.title,
|
||||||
onValueChange = viewModel::setTitle,
|
onValueChange = viewModel::setTitle,
|
||||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||||
textStyle = MaterialTheme.typography.headlineMedium
|
textStyle = MaterialTheme.typography.headlineMedium
|
||||||
.copy(fontWeight = FontWeight.SemiBold),
|
.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
singleLine = false,
|
||||||
enabled = !locked,
|
enabled = !locked,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
// Ease the height change as the title wraps to another line
|
||||||
|
// instead of letting the accent bar + cards below jump.
|
||||||
|
.animateContentSizeMotion()
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.focusRequester(titleFocusRequester),
|
.focusRequester(titleFocusRequester),
|
||||||
)
|
)
|
||||||
@@ -608,15 +672,16 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
|
|
||||||
// Calendar card — tap anywhere to pick the target calendar. Editing
|
// Calendar card — tap anywhere to pick the target calendar. Picking a
|
||||||
// keeps the owning calendar (moving events between calendars is a
|
// different one while editing *moves* the event (copy+delete on save,
|
||||||
// sync-adapter minefield; every stock calendar app locks it too).
|
// since CALENDAR_ID can't be updated in place). Managed special-dates
|
||||||
|
// events stay locked: the contact sync owns their calendar.
|
||||||
EditCard(
|
EditCard(
|
||||||
icon = Icons.Default.CalendarMonth,
|
icon = Icons.Default.CalendarMonth,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
iconContentDescription = stringResource(R.string.event_detail_calendar),
|
||||||
iconTint = accent,
|
iconTint = accent,
|
||||||
onClick = { showCalendarPicker = true }
|
onClick = { showCalendarPicker = true }
|
||||||
.takeIf { state.calendars.isNotEmpty() && !state.isEditing },
|
.takeIf { state.calendars.isNotEmpty() && !state.isManaged },
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = selectedCalendar?.displayName
|
text = selectedCalendar?.displayName
|
||||||
@@ -1055,24 +1120,19 @@ private fun FieldPickerDialog(
|
|||||||
onSelect: (EventFormField) -> Unit,
|
onSelect: (EventFormField) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
FullScreenPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_more_fields),
|
||||||
title = { Text(stringResource(R.string.event_edit_more_fields)) },
|
onDismiss = onDismiss,
|
||||||
text = {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
hiddenFields.forEachIndexed { index, field ->
|
||||||
hiddenFields.forEach { field ->
|
GroupedRow(
|
||||||
OptionCard(
|
title = stringResource(eventFormFieldLabel(field)),
|
||||||
label = stringResource(eventFormFieldLabel(field)),
|
position = positionOf(index, hiddenFields.size),
|
||||||
onClick = { onSelect(field) },
|
leading = { Icon(imageVector = eventFormFieldIcon(field), contentDescription = null) },
|
||||||
icon = eventFormFieldIcon(field),
|
onClick = { onSelect(field) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
||||||
@@ -1097,41 +1157,10 @@ private fun ReminderPickerDialog(
|
|||||||
?.takeIf { it in 1..999 }
|
?.takeIf { it in 1..999 }
|
||||||
?.let { it * unit.minutesFactor }
|
?.let { it * unit.minutesFactor }
|
||||||
|
|
||||||
AlertDialog(
|
FullScreenPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_add_reminder),
|
||||||
title = { Text(stringResource(R.string.event_edit_add_reminder)) },
|
onDismiss = onDismiss,
|
||||||
text = {
|
actions = {
|
||||||
if (!customMode) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }.forEach { minutes ->
|
|
||||||
OptionCard(
|
|
||||||
label = reminderLabel(minutes),
|
|
||||||
onClick = { onSelect(minutes) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_edit_reminder_custom),
|
|
||||||
onClick = { customMode = true },
|
|
||||||
labelColor = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
DialogAmountField(
|
|
||||||
value = amountText,
|
|
||||||
onValueChange = { amountText = it },
|
|
||||||
placeholder = "10",
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
DialogUnitDropdown(
|
|
||||||
label = stringResource(reminderUnitLabel(unit)),
|
|
||||||
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
|
||||||
onPick = { unit = ReminderUnit.entries[it] },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
// The quick-pick list adds on tap; only the custom step needs Add.
|
// The quick-pick list adds on tap; only the custom step needs Add.
|
||||||
if (customMode) {
|
if (customMode) {
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -1140,10 +1169,41 @@ private fun ReminderPickerDialog(
|
|||||||
) { Text(stringResource(R.string.event_edit_add)) }
|
) { Text(stringResource(R.string.event_edit_add)) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
) {
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
if (!customMode) {
|
||||||
},
|
val presets = REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }
|
||||||
)
|
val rowCount = presets.size + 1
|
||||||
|
presets.forEachIndexed { index, minutes ->
|
||||||
|
GroupedRow(
|
||||||
|
title = reminderLabel(minutes),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
onClick = { onSelect(minutes) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_reminder_custom),
|
||||||
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
|
onClick = { customMode = true },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = { amountText = it },
|
||||||
|
placeholder = "10",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogUnitDropdown(
|
||||||
|
label = stringResource(reminderUnitLabel(unit)),
|
||||||
|
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||||
|
onPick = { unit = ReminderUnit.entries[it] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
|
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
|
||||||
@@ -1213,109 +1273,10 @@ private fun RecurrencePickerDialog(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
AlertDialog(
|
FullScreenPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_detail_recurrence),
|
||||||
title = { Text(stringResource(R.string.event_detail_recurrence)) },
|
onDismiss = onDismiss,
|
||||||
text = {
|
actions = {
|
||||||
if (!customMode) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_edit_recurrence_none),
|
|
||||||
onClick = { onSelect(null) },
|
|
||||||
selected = current == null,
|
|
||||||
)
|
|
||||||
RecurrenceFreq.entries.forEach { entry ->
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(recurrencePresetLabel(entry)),
|
|
||||||
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
|
||||||
selected = isPlainPreset && parsed?.freq == entry,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_edit_recurrence_custom),
|
|
||||||
onClick = { customMode = true },
|
|
||||||
selected = current != null && !isPlainPreset,
|
|
||||||
labelColor = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.event_edit_recurrence_every),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
DialogAmountField(
|
|
||||||
value = intervalText,
|
|
||||||
onValueChange = { intervalText = it },
|
|
||||||
placeholder = "1",
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
DialogUnitDropdown(
|
|
||||||
label = stringResource(recurrenceUnitLabel(freq)),
|
|
||||||
entries = RecurrenceFreq.entries.map {
|
|
||||||
stringResource(recurrenceUnitLabel(it))
|
|
||||||
},
|
|
||||||
onPick = { freq = RecurrenceFreq.entries[it] },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (freq == RecurrenceFreq.Weekly) {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
WeekdayToggleRow(
|
|
||||||
selected = daysMask.toDaySet(),
|
|
||||||
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
|
||||||
locale = locale,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.event_edit_recurrence_ends),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_edit_recurrence_end_never),
|
|
||||||
onClick = { endMode = RecurrenceEndMode.Never },
|
|
||||||
selected = endMode == RecurrenceEndMode.Never,
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_edit_recurrence_end_until),
|
|
||||||
onClick = {
|
|
||||||
endMode = RecurrenceEndMode.Until
|
|
||||||
showUntilPicker = true
|
|
||||||
},
|
|
||||||
supportingText = untilDate?.let {
|
|
||||||
remember(it, locale) {
|
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
|
||||||
.withLocale(locale).format(it.toJavaLocalDate())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selected = endMode == RecurrenceEndMode.Until,
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_edit_recurrence_end_count),
|
|
||||||
onClick = { endMode = RecurrenceEndMode.Count },
|
|
||||||
selected = endMode == RecurrenceEndMode.Count,
|
|
||||||
)
|
|
||||||
if (endMode == RecurrenceEndMode.Count) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
DialogAmountField(
|
|
||||||
value = countText,
|
|
||||||
onValueChange = { countText = it },
|
|
||||||
placeholder = "10",
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.event_edit_recurrence_times),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
// The preset list applies on tap; only the custom step needs OK.
|
// The preset list applies on tap; only the custom step needs OK.
|
||||||
if (customMode) {
|
if (customMode) {
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -1324,10 +1285,113 @@ private fun RecurrencePickerDialog(
|
|||||||
) { Text(stringResource(R.string.dialog_ok)) }
|
) { Text(stringResource(R.string.dialog_ok)) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
) {
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
if (!customMode) {
|
||||||
},
|
val rowCount = RecurrenceFreq.entries.size + 2
|
||||||
)
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_recurrence_none),
|
||||||
|
position = positionOf(0, rowCount),
|
||||||
|
selected = current == null,
|
||||||
|
onClick = { onSelect(null) },
|
||||||
|
)
|
||||||
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(recurrencePresetLabel(entry)),
|
||||||
|
position = positionOf(index + 1, rowCount),
|
||||||
|
selected = isPlainPreset && parsed?.freq == entry,
|
||||||
|
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_recurrence_custom),
|
||||||
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
|
selected = current != null && !isPlainPreset,
|
||||||
|
onClick = { customMode = true },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_recurrence_every),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogAmountField(
|
||||||
|
value = intervalText,
|
||||||
|
onValueChange = { intervalText = it },
|
||||||
|
placeholder = "1",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogUnitDropdown(
|
||||||
|
label = stringResource(recurrenceUnitLabel(freq)),
|
||||||
|
entries = RecurrenceFreq.entries.map {
|
||||||
|
stringResource(recurrenceUnitLabel(it))
|
||||||
|
},
|
||||||
|
onPick = { freq = RecurrenceFreq.entries[it] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (freq == RecurrenceFreq.Weekly) {
|
||||||
|
WeekdayToggleRow(
|
||||||
|
selected = daysMask.toDaySet(),
|
||||||
|
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
||||||
|
locale = locale,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_recurrence_ends),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_recurrence_end_never),
|
||||||
|
position = positionOf(0, 3),
|
||||||
|
selected = endMode == RecurrenceEndMode.Never,
|
||||||
|
onClick = { endMode = RecurrenceEndMode.Never },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_recurrence_end_until),
|
||||||
|
summary = untilDate?.let {
|
||||||
|
remember(it, locale) {
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
|
.withLocale(locale).format(it.toJavaLocalDate())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
position = positionOf(1, 3),
|
||||||
|
selected = endMode == RecurrenceEndMode.Until,
|
||||||
|
onClick = {
|
||||||
|
endMode = RecurrenceEndMode.Until
|
||||||
|
showUntilPicker = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_recurrence_end_count),
|
||||||
|
position = positionOf(2, 3),
|
||||||
|
selected = endMode == RecurrenceEndMode.Count,
|
||||||
|
onClick = { endMode = RecurrenceEndMode.Count },
|
||||||
|
)
|
||||||
|
if (endMode == RecurrenceEndMode.Count) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = countText,
|
||||||
|
onValueChange = { countText = it },
|
||||||
|
placeholder = "10",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_recurrence_times),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showUntilPicker) {
|
if (showUntilPicker) {
|
||||||
CalendarDatePickerDialog(
|
CalendarDatePickerDialog(
|
||||||
@@ -1611,6 +1675,7 @@ private fun AddGuestInlineCard(
|
|||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
keyboardType = KeyboardType.Email,
|
keyboardType = KeyboardType.Email,
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
onImeAction = { commit() },
|
onImeAction = { commit() },
|
||||||
@@ -1691,24 +1756,14 @@ private fun VisibilityPickerDialog(
|
|||||||
onSelect: (AccessLevel) -> Unit,
|
onSelect: (AccessLevel) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
OptionPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_visibility),
|
||||||
title = { Text(stringResource(R.string.event_edit_visibility)) },
|
options = AccessLevel.entries.toList(),
|
||||||
text = {
|
selected = selected,
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
label = { stringResource(accessLevelLabel(it)) },
|
||||||
AccessLevel.entries.forEach { level ->
|
onSelect = onSelect,
|
||||||
OptionCard(
|
onDismiss = onDismiss,
|
||||||
label = stringResource(accessLevelLabel(level)),
|
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
||||||
onClick = { onSelect(level) },
|
|
||||||
icon = accessLevelIcon(level),
|
|
||||||
selected = level == selected,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1731,51 +1786,57 @@ private fun ColorPickerDialog(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
AlertDialog(
|
FullScreenPicker(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_color),
|
||||||
title = { Text(stringResource(R.string.event_edit_color)) },
|
onDismiss = onDismiss,
|
||||||
text = {
|
actions = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
if (hasExplicitColor) {
|
||||||
if (palette.isNotEmpty()) {
|
|
||||||
ColorSwatchRow(
|
|
||||||
colors = palette.map { it.argb },
|
|
||||||
selected = selected,
|
|
||||||
onSelect = { argb ->
|
|
||||||
palette.firstOrNull { it.argb == argb }
|
|
||||||
?.let { onPickKey(it.key, it.argb) }
|
|
||||||
},
|
|
||||||
dark = dark,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ColorSwatchRow(
|
|
||||||
colors = CalendarColorPalette.all,
|
|
||||||
selected = selected,
|
|
||||||
onSelect = onPickRaw,
|
|
||||||
dark = dark,
|
|
||||||
)
|
|
||||||
if (syncWarning) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.event_edit_color_sync_warning),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
dismissButton = if (hasExplicitColor) {
|
|
||||||
{
|
|
||||||
TextButton(onClick = onClear) {
|
TextButton(onClick = onClear) {
|
||||||
Text(stringResource(R.string.event_edit_color_reset))
|
Text(stringResource(R.string.event_edit_color_reset))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
},
|
||||||
)
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
if (palette.isNotEmpty()) {
|
||||||
|
// The event's current colour may not be in the curated palette
|
||||||
|
// (a thinned near-duplicate, or a raw colour set elsewhere) —
|
||||||
|
// append it so the selection ring has a home.
|
||||||
|
val swatches = palette.map { it.argb }.let {
|
||||||
|
if (selected != null && selected !in it) it + selected else it
|
||||||
|
}
|
||||||
|
ColorSwatchRow(
|
||||||
|
colors = swatches,
|
||||||
|
selected = selected,
|
||||||
|
onSelect = { argb ->
|
||||||
|
val option = palette.firstOrNull { it.argb == argb }
|
||||||
|
// The appended current colour has no provider key to
|
||||||
|
// write — it is already the event's colour, so just
|
||||||
|
// close.
|
||||||
|
if (option != null) onPickKey(option.key, option.argb) else onDismiss()
|
||||||
|
},
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ColorSwatchRow(
|
||||||
|
colors = CalendarColorPalette.all,
|
||||||
|
selected = selected,
|
||||||
|
onSelect = onPickRaw,
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
|
if (syncWarning) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_color_sync_warning),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
|
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
|
||||||
@@ -1899,6 +1960,7 @@ private fun InlineField(
|
|||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -50,6 +51,36 @@ import kotlin.time.Duration.Companion.hours
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
||||||
|
* sources want different reminder handling (#49).
|
||||||
|
*/
|
||||||
|
enum class ImportSource {
|
||||||
|
/**
|
||||||
|
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
||||||
|
* It carries no reminders of its own, so the settings default is applied
|
||||||
|
* automatically, exactly like an in-app new event.
|
||||||
|
*/
|
||||||
|
Insert,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
||||||
|
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
||||||
|
* rather than silently applied or suppressed.
|
||||||
|
*/
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pending offer to swap an imported `.ics` event's reminders for the settings
|
||||||
|
* default (#49). [currentReminderCount] is what the file carried (0 or more);
|
||||||
|
* [defaultReminders] is what accepting would set.
|
||||||
|
*/
|
||||||
|
data class ImportReminderPrompt(
|
||||||
|
val currentReminderCount: Int,
|
||||||
|
val defaultReminders: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the event form being composed. The form's calendar id resolves to
|
* Holds the event form being composed. The form's calendar id resolves to
|
||||||
* (user pick > last used > first writable); the resolved value is what the UI
|
* (user pick > last used > first writable); the resolved value is what the UI
|
||||||
@@ -78,10 +109,16 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
// their choice. Reset with the form.
|
// their choice. Reset with the form.
|
||||||
private val _remindersTouched = MutableStateFlow(false)
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||||
|
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||||
|
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
|
|
||||||
|
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
|
||||||
|
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event being edited plus everything the form saw at load time.
|
* The event being edited plus everything the form saw at load time.
|
||||||
* For recurring events the write scope is chosen at save time; the
|
* For recurring events the write scope is chosen at save time; the
|
||||||
@@ -112,7 +149,17 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val allDay: List<Int>,
|
val allDay: List<Int>,
|
||||||
val timedOverrides: Map<Long, List<Int>>,
|
val timedOverrides: Map<Long, List<Int>>,
|
||||||
val allDayOverrides: Map<Long, List<Int>>,
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
)
|
) {
|
||||||
|
/** The default reminders for an event on [calendarId] of the given kind. */
|
||||||
|
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
|
||||||
|
timedGlobal = timed,
|
||||||
|
allDayGlobal = allDay,
|
||||||
|
timedOverrides = timedOverrides,
|
||||||
|
allDayOverrides = allDayOverrides,
|
||||||
|
calendarId = calendarId,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
@@ -241,18 +288,39 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
|
||||||
* the create form" path). [form] already carries the file's fields; its
|
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
|
||||||
* [EventForm.calendarId] is null so the calendar still resolves to the
|
* app/widget creating an event, e.g. Google Maps' "add to calendar";
|
||||||
* last-used/first-writable one, and reminders are frozen as touched so the
|
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
|
||||||
* settings default never overwrites what the file specified. No-op when a
|
* calendar still resolves to the last-used/first-writable one.
|
||||||
* form is already open, so the prefill survives configuration changes.
|
*
|
||||||
|
* Reminders are handled per [source], because the two paths mean different
|
||||||
|
* things by "no reminders":
|
||||||
|
* - [ImportSource.Insert] carries no reminder semantics, so the settings
|
||||||
|
* default is applied automatically like an in-app new event (a form that
|
||||||
|
* somehow did carry reminders keeps them, frozen).
|
||||||
|
* - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
|
||||||
|
* settings default is configured and differs, [importReminderPrompt] offers
|
||||||
|
* to swap it in rather than silently deciding for the user.
|
||||||
|
*
|
||||||
|
* No-op when a form is already open, so the prefill survives configuration
|
||||||
|
* changes.
|
||||||
*/
|
*/
|
||||||
fun openImported(form: EventForm) {
|
fun openImported(form: EventForm, source: ImportSource) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
_remindersTouched.value = true
|
|
||||||
_revealed.value = form.populatedFields()
|
_revealed.value = form.populatedFields()
|
||||||
_form.value = form
|
_form.value = form
|
||||||
|
when (source) {
|
||||||
|
ImportSource.Insert ->
|
||||||
|
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||||
|
else applyDefaultReminder()
|
||||||
|
|
||||||
|
ImportSource.File -> {
|
||||||
|
// Respect the file's own reminders; never silently overwrite them.
|
||||||
|
_remindersTouched.value = true
|
||||||
|
maybePromptImportedReminderDefault(form)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,26 +334,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
private fun applyDefaultReminder(calendarId: Long? = null) {
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val defaults = combine(
|
val defaults = reminderDefaults()
|
||||||
settingsPrefs.defaultReminderMinutes,
|
|
||||||
settingsPrefs.defaultAllDayReminderMinutes,
|
|
||||||
settingsPrefs.perCalendarReminderOverride,
|
|
||||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
|
||||||
) { timed, allDay, timedOv, allDayOv ->
|
|
||||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
|
||||||
}.first()
|
|
||||||
val targetId = calendarId ?: resolvedCalendarId.first()
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
// Re-check after suspending: bail if the form closed or the user edited.
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val reminders = resolveDefaultReminder(
|
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||||
timedGlobal = defaults.timed,
|
|
||||||
allDayGlobal = defaults.allDay,
|
|
||||||
timedOverrides = defaults.timedOverrides,
|
|
||||||
allDayOverrides = defaults.allDayOverrides,
|
|
||||||
calendarId = targetId,
|
|
||||||
isAllDay = form.isAllDay,
|
|
||||||
)
|
|
||||||
_form.value = form.copy(reminders = reminders)
|
_form.value = form.copy(reminders = reminders)
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
@@ -295,10 +349,60 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot the four settings-default reminder flows into one value. */
|
||||||
|
private suspend fun reminderDefaults(): ReminderDefaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||||
|
* file often has none while the user still expects their configured default.
|
||||||
|
* Rather than silently deciding, raise a one-time offer to swap in the
|
||||||
|
* settings default — but only when there's a real choice: a default is
|
||||||
|
* configured and it isn't already exactly what the file carried.
|
||||||
|
*/
|
||||||
|
private fun maybePromptImportedReminderDefault(form: EventForm) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val targetId = resolvedCalendarId.first()
|
||||||
|
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
||||||
|
// Bail if the form closed or became an edit while we resolved.
|
||||||
|
val current = _form.value ?: return@launch
|
||||||
|
if (_editTarget.value != null) return@launch
|
||||||
|
if (default.isEmpty() || default == current.reminders) return@launch
|
||||||
|
_importReminderPrompt.value = ImportReminderPrompt(
|
||||||
|
currentReminderCount = current.reminders.size,
|
||||||
|
defaultReminders = default,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accept the import prompt: replace the file's reminders with the default. */
|
||||||
|
fun applyImportedReminderDefault() {
|
||||||
|
val prompt = _importReminderPrompt.value ?: return
|
||||||
|
_importReminderPrompt.value = null
|
||||||
|
// Already frozen as touched by openImported; this just swaps the values.
|
||||||
|
update { it.copy(reminders = prompt.defaultReminders) }
|
||||||
|
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decline the import prompt: keep the file's own reminders untouched. */
|
||||||
|
fun dismissImportedReminderPrompt() {
|
||||||
|
_importReminderPrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||||
* tapped occurrence's own times, like on the detail screen. No-op while a
|
* tapped occurrence's own times, like on the detail screen. An external
|
||||||
* form is open, so user edits survive configuration changes.
|
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
|
||||||
|
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
|
||||||
|
* form loads the event's real times instead of the epoch (mirrors the
|
||||||
|
* detail screen's #48 fallback). No-op while a form is open, so user edits
|
||||||
|
* survive configuration changes.
|
||||||
*/
|
*/
|
||||||
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
@@ -312,8 +416,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = TimeZone.currentSystemDefault()
|
||||||
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||||
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
?: detail.instance.start.toEpochMilliseconds()
|
||||||
|
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||||
|
?: detail.instance.end.toEpochMilliseconds()
|
||||||
|
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
||||||
|
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
|
||||||
// Sections holding data must show even when not in the defaults.
|
// Sections holding data must show even when not in the defaults.
|
||||||
_revealed.value = snapshot.form.populatedFields()
|
_revealed.value = snapshot.form.populatedFields()
|
||||||
_form.value = snapshot.form
|
_form.value = snapshot.form
|
||||||
@@ -329,6 +437,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
_remindersTouched.value = false
|
_remindersTouched.value = false
|
||||||
|
_importReminderPrompt.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
@@ -336,7 +445,11 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_revealed.value = _revealed.value + field
|
_revealed.value = _revealed.value + field
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTitle(value: String) = update { it.copy(title = value) }
|
// The title field wraps (multi-line) so long titles stay visible (#33), but
|
||||||
|
// a title is one logical line: drop any newline the IME's Enter key or a
|
||||||
|
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||||
|
fun setTitle(value: String) =
|
||||||
|
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) {
|
fun setAllDay(value: Boolean) {
|
||||||
@@ -435,10 +548,15 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_saveState.value = SaveUiState.Saved
|
_saveState.value = SaveUiState.Saved
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Changing the calendar moves the event (copy+delete — CALENDAR_ID can't
|
||||||
|
// be updated in place) and is inherently whole-series: an occurrence
|
||||||
|
// can't live in a different calendar than its series, so a move skips the
|
||||||
|
// scope dialog even for a recurring event.
|
||||||
|
val movingCalendar = target != null && form.calendarId != target.original.calendarId
|
||||||
// Managed events are a yearly series whose editable fields (reminders,
|
// Managed events are a yearly series whose editable fields (reminders,
|
||||||
// notes, location) live on the series row — never offer the scope dialog,
|
// notes, location) live on the series row — never offer the scope dialog,
|
||||||
// which would split the series into an exception the sync then reverts.
|
// which would split the series into an exception the sync then reverts.
|
||||||
if (target != null && target.original.rrule != null && !current.isManaged) {
|
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
|
||||||
_saveState.value = SaveUiState.AwaitingScope
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -493,6 +611,17 @@ class EventEditViewModel @Inject constructor(
|
|||||||
if (target == null) {
|
if (target == null) {
|
||||||
repository.createEvent(form)
|
repository.createEvent(form)
|
||||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
|
} else if (form.calendarId != target.original.calendarId) {
|
||||||
|
// Move to the picked calendar (copy+delete), carrying any
|
||||||
|
// field edits made in the same save. The target becomes the
|
||||||
|
// last-used calendar, like a create does.
|
||||||
|
repository.moveEvent(
|
||||||
|
eventId = target.eventId,
|
||||||
|
targetCalendarId = requireNotNull(form.calendarId),
|
||||||
|
original = target.original,
|
||||||
|
updated = form,
|
||||||
|
)
|
||||||
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
} else {
|
} else {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringWriteScope.ThisEvent ->
|
RecurringWriteScope.ThisEvent ->
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles an opened/received `.ics` file. A single event is handed straight to
|
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||||
|
|||||||
@@ -75,16 +75,18 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
@@ -108,6 +110,7 @@ fun MonthScreen(
|
|||||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
|
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
||||||
// The instant before which an event counts as completed, or null when dimming
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
// screen while the setting is off (it stays null regardless of the tick).
|
// screen while the setting is off (it stays null regardless of the tick).
|
||||||
@@ -211,11 +214,12 @@ fun MonthScreen(
|
|||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
WeekdayHeader(weekStart = weekStart)
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
MonthContent(
|
MonthContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
@@ -231,6 +235,7 @@ fun MonthScreen(
|
|||||||
private fun MonthContent(
|
private fun MonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@@ -276,6 +281,7 @@ private fun MonthContent(
|
|||||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is MonthUiState.Success -> MonthGrid(
|
is MonthUiState.Success -> MonthGrid(
|
||||||
state = s,
|
state = s,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -325,7 +331,7 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekdayHeader(weekStart: DayOfWeek) {
|
private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val days = remember(weekStart, locale) {
|
val days = remember(weekStart, locale) {
|
||||||
(0 until 7).map { offset ->
|
(0 until 7).map { offset ->
|
||||||
@@ -337,6 +343,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
|
// Reserve the gutter so the weekday labels stay over their day columns.
|
||||||
|
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||||
days.forEach { dow ->
|
days.forEach { dow ->
|
||||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||||
@@ -354,6 +362,9 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
|
|||||||
|
|
||||||
private val EVENT_ROW_HEIGHT = 20.dp
|
private val EVENT_ROW_HEIGHT = 20.dp
|
||||||
private val DAY_NUMBER_HEIGHT = 22.dp
|
private val DAY_NUMBER_HEIGHT = 22.dp
|
||||||
|
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
|
||||||
|
* seats a one- or two-digit week number in a full-height tonal pill. */
|
||||||
|
private val WEEK_NUMBER_GUTTER = 40.dp
|
||||||
private val DAY_NUMBER_GAP = 4.dp
|
private val DAY_NUMBER_GAP = 4.dp
|
||||||
private val CELL_TOP_PADDING = 6.dp
|
private val CELL_TOP_PADDING = 6.dp
|
||||||
private val CELL_GAP = 2.dp
|
private val CELL_GAP = 2.dp
|
||||||
@@ -363,12 +374,16 @@ private const val MAX_EVENT_ROWS = 3
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthGrid(
|
private fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp),
|
// Match the weekday header's 8dp inset so day cells sit under their
|
||||||
|
// labels, and so the week-number gutter's centre lines up with the
|
||||||
|
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
state.weeks.forEach { week ->
|
state.weeks.forEach { week ->
|
||||||
@@ -376,6 +391,7 @@ private fun MonthGrid(
|
|||||||
week = week,
|
week = week,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
month = state.month,
|
month = state.month,
|
||||||
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -397,6 +413,7 @@ private fun MonthWeekRow(
|
|||||||
week: MonthWeek,
|
week: MonthWeek,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
month: YearMonth,
|
month: YearMonth,
|
||||||
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -404,135 +421,179 @@ private fun MonthWeekRow(
|
|||||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
|
|
||||||
BoxWithConstraints(modifier) {
|
Row(modifier) {
|
||||||
val colW = maxWidth / 7
|
// Optional calendar-week gutter, sized so the seven day columns below
|
||||||
|
// divide the remaining width — the absolute bar offsets stay correct
|
||||||
// Per-day background pills — same surfaceContainer rounded surface the
|
// because they're measured inside the grid box, not the whole row.
|
||||||
// week/day views use, so the three views share one visual language.
|
if (showWeekNumbers) {
|
||||||
// Spanning bars draw on top of these, bridging cells, so they still read
|
WeekNumberGutter(
|
||||||
// as one continuous event.
|
weekStart = week.days.first(),
|
||||||
Row(Modifier.matchParentSize()) {
|
|
||||||
week.days.forEach { d ->
|
|
||||||
val inMonth = d.month == month.month && d.year == month.year
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
|
||||||
.background(
|
|
||||||
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
else MaterialTheme.colorScheme.surfaceContainerLow,
|
|
||||||
shape = CELL_SHAPE,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
|
|
||||||
Row(Modifier.fillMaxWidth()) {
|
|
||||||
week.days.forEach { d ->
|
|
||||||
DayNumberCell(
|
|
||||||
date = d,
|
|
||||||
isToday = d == today,
|
|
||||||
inMonth = d.month == month.month && d.year == month.year,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Breathing room between the day number (and today's circle) and the
|
|
||||||
// first event row.
|
|
||||||
Spacer(Modifier.height(DAY_NUMBER_GAP))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.width(WEEK_NUMBER_GUTTER)
|
||||||
.weight(1f)
|
.fillMaxHeight(),
|
||||||
.clipToBounds(),
|
)
|
||||||
) {
|
}
|
||||||
// Spanning bars on their shared lanes.
|
BoxWithConstraints(
|
||||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
Modifier
|
||||||
val cols = span.endCol - span.startCol + 1
|
.weight(1f)
|
||||||
MonthBar(
|
.fillMaxHeight(),
|
||||||
event = span.event,
|
) {
|
||||||
dark = dark,
|
val colW = maxWidth / 7
|
||||||
continuesLeft = span.continuesLeft,
|
|
||||||
continuesRight = span.continuesRight,
|
// Per-day background pills — same surfaceContainer rounded surface the
|
||||||
modifier = Modifier
|
// week/day views use, so the three views share one visual language.
|
||||||
.offset(
|
// Spanning bars draw on top of these, bridging cells, so they still read
|
||||||
x = colW * span.startCol,
|
// as one continuous event.
|
||||||
y = EVENT_ROW_HEIGHT * span.lane,
|
Row(Modifier.matchParentSize()) {
|
||||||
)
|
week.days.forEach { d ->
|
||||||
.width(colW * cols)
|
val inMonth = d.month == month.month && d.year == month.year
|
||||||
.height(EVENT_ROW_HEIGHT)
|
Box(
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.background(
|
||||||
|
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
shape = CELL_SHAPE,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Single-day timed pills + overflow, per column. Pills fill the
|
}
|
||||||
// lane slots no bar occupies on THIS day (top-most first), so a
|
|
||||||
// bar-free day isn't pushed down by a multi-day event that only
|
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
|
||||||
// sits on other days of the week.
|
Row(Modifier.fillMaxWidth()) {
|
||||||
week.days.forEachIndexed { col, d ->
|
week.days.forEach { d ->
|
||||||
val timed = week.timedByDay[d].orEmpty()
|
DayNumberCell(
|
||||||
val occupied = week.spans
|
date = d,
|
||||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
isToday = d == today,
|
||||||
.map { it.lane }
|
inMonth = d.month == month.month && d.year == month.year,
|
||||||
.toSet()
|
modifier = Modifier.weight(1f),
|
||||||
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
|
)
|
||||||
val pillsShown = timed.take(freeSlots.size)
|
}
|
||||||
pillsShown.forEachIndexed { i, ev ->
|
}
|
||||||
|
// Breathing room between the day number (and today's circle) and the
|
||||||
|
// first event row.
|
||||||
|
Spacer(Modifier.height(DAY_NUMBER_GAP))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
// Spanning bars on their shared lanes.
|
||||||
|
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||||
|
val cols = span.endCol - span.startCol + 1
|
||||||
MonthBar(
|
MonthBar(
|
||||||
event = ev,
|
event = span.event,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = false,
|
continuesLeft = span.continuesLeft,
|
||||||
continuesRight = false,
|
continuesRight = span.continuesRight,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * col,
|
x = colW * span.startCol,
|
||||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
y = EVENT_ROW_HEIGHT * span.lane,
|
||||||
)
|
)
|
||||||
.width(colW)
|
.width(colW * cols)
|
||||||
.height(EVENT_ROW_HEIGHT)
|
.height(EVENT_ROW_HEIGHT)
|
||||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
// Single-day timed pills + overflow, per column. Pills fill the
|
||||||
if (hidden > 0) {
|
// lane slots no bar occupies on THIS day (top-most first), so a
|
||||||
val hiddenColors = buildList {
|
// bar-free day isn't pushed down by a multi-day event that only
|
||||||
week.spans
|
// sits on other days of the week.
|
||||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
week.days.forEachIndexed { col, d ->
|
||||||
.forEach { add(it.event.color) }
|
val timed = week.timedByDay[d].orEmpty()
|
||||||
timed.drop(pillsShown.size).forEach { add(it.color) }
|
val occupied = week.spans
|
||||||
}.distinct().take(3)
|
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||||
OverflowDots(
|
.map { it.lane }
|
||||||
colors = hiddenColors,
|
.toSet()
|
||||||
extra = hidden - hiddenColors.size,
|
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
|
||||||
dark = dark,
|
val pillsShown = timed.take(freeSlots.size)
|
||||||
modifier = Modifier
|
pillsShown.forEachIndexed { i, ev ->
|
||||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
MonthBar(
|
||||||
.width(colW)
|
event = ev,
|
||||||
.padding(horizontal = 3.dp),
|
dark = dark,
|
||||||
)
|
continuesLeft = false,
|
||||||
|
continuesRight = false,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(
|
||||||
|
x = colW * col,
|
||||||
|
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||||
|
)
|
||||||
|
.width(colW)
|
||||||
|
.height(EVENT_ROW_HEIGHT)
|
||||||
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||||
|
if (hidden > 0) {
|
||||||
|
val hiddenColors = buildList {
|
||||||
|
week.spans
|
||||||
|
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||||
|
.forEach { add(it.event.color) }
|
||||||
|
timed.drop(pillsShown.size).forEach { add(it.color) }
|
||||||
|
}.distinct().take(3)
|
||||||
|
OverflowDots(
|
||||||
|
colors = hiddenColors,
|
||||||
|
extra = hidden - hiddenColors.size,
|
||||||
|
dark = dark,
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||||
|
.width(colW)
|
||||||
|
.padding(horizontal = 3.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Tap layer: in month view a tap on any day opens that day. Padded and
|
// Tap layer: in month view a tap on any day opens that day. Padded and
|
||||||
// clipped to the background pill so the ripple matches it.
|
// clipped to the background pill so the ripple matches it.
|
||||||
Row(Modifier.matchParentSize()) {
|
Row(Modifier.matchParentSize()) {
|
||||||
week.days.forEach { d ->
|
week.days.forEach { d ->
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
.clip(CELL_SHAPE)
|
.clip(CELL_SHAPE)
|
||||||
.clickable { onOpenDay(d) },
|
.clickable { onOpenDay(d) },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||||
|
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||||
|
* Week view's badge), with the ISO week number centred like a day number. The
|
||||||
|
* week is computed on the row's first day — the same basis as the Week view — so
|
||||||
|
* the two agree.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
|
||||||
|
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||||
|
val label = stringResource(R.string.week_number_label)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
|
||||||
|
.semantics { contentDescription = "$label $weekNumber" },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = weekNumber.toString(),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayNumberCell(
|
private fun DayNumberCell(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Whether to show the calendar-week number gutter (#25; display only). */
|
||||||
|
val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
||||||
|
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
||||||
|
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
||||||
|
* A lock badge overlays the corner when permission has been [denied].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun BrandHero(denied: Boolean) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (denied) {
|
||||||
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Lock
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.res.colorResource
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
|
||||||
internal object OnboardingSpace {
|
|
||||||
val xs = 8.dp
|
|
||||||
val sm = 16.dp
|
|
||||||
val md = 24.dp
|
|
||||||
val lg = 32.dp
|
|
||||||
val xl = 48.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
|
|
||||||
* centred hero + body with the call(s) to action pinned to the bottom (clear
|
|
||||||
* of the navigation bar). The content slot is centred horizontally; benefit
|
|
||||||
* rows fill the width so their own content left-aligns.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun OnboardingScaffold(
|
|
||||||
hero: @Composable () -> Unit,
|
|
||||||
actions: @Composable ColumnScope.() -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
body: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
bottomBar = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.navigationBarsPadding()
|
|
||||||
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
content = actions,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = OnboardingSpace.md),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
|
||||||
hero()
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
|
||||||
body()
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.md))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
|
||||||
@Composable
|
|
||||||
internal fun BrandHero(denied: Boolean) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
.clip(RoundedCornerShape(34.dp))
|
|
||||||
.background(colorResource(R.color.ic_launcher_background)),
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
|
||||||
contentDescription = stringResource(R.string.app_name),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (denied) {
|
|
||||||
// A small lock badge sits over the corner to signal "blocked".
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.offset(x = 10.dp, y = 10.dp)
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.errorContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
|
||||||
@Composable
|
|
||||||
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(OnboardingSpace.sm))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
|
||||||
Text(
|
|
||||||
text = body,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
|||||||
@@ -42,22 +42,23 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -101,6 +102,7 @@ fun SearchScreen(
|
|||||||
value = query,
|
value = query,
|
||||||
onValueChange = viewModel::setQuery,
|
onValueChange = viewModel::setQuery,
|
||||||
placeholder = stringResource(R.string.search_hint),
|
placeholder = stringResource(R.string.search_hint),
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
imeAction = ImeAction.Search,
|
imeAction = ImeAction.Search,
|
||||||
onImeAction = { keyboard?.hide() },
|
onImeAction = { keyboard?.hide() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -175,7 +177,7 @@ private fun SearchResults(
|
|||||||
SearchResultRow(
|
SearchResultRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, events.size),
|
position = positionOf(index, events.size),
|
||||||
modifier = calendarAnimateItem(),
|
modifier = animateItemMotion(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.core.os.LocaleListCompat
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import org.xmlpull.v1.XmlPullParser
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
|
||||||
*
|
|
||||||
* That file is the single source of truth for which languages we ship: dropping
|
|
||||||
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
|
||||||
* language show up here and in the system per-app-language settings, with no
|
|
||||||
* other code change. The system-default choice is represented as `null`.
|
|
||||||
*
|
|
||||||
* On API 33+ this delegates to the platform per-app-languages API; below that
|
|
||||||
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
|
||||||
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
|
||||||
* activity, which re-reads the current value for the picker.
|
|
||||||
*/
|
|
||||||
object AppLanguage {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The BCP-47 tags the app ships translations for, in declaration order, as
|
|
||||||
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
|
||||||
* or malformed config yields an empty list (the picker then offers only the
|
|
||||||
* system-default entry rather than crashing).
|
|
||||||
*/
|
|
||||||
fun supportedTags(context: Context): List<String> {
|
|
||||||
val tags = mutableListOf<String>()
|
|
||||||
val parser = context.resources.getXml(R.xml.locales_config)
|
|
||||||
try {
|
|
||||||
var event = parser.eventType
|
|
||||||
while (event != XmlPullParser.END_DOCUMENT) {
|
|
||||||
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
|
||||||
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
|
||||||
}
|
|
||||||
event = parser.next()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Fall back to whatever was parsed before the failure.
|
|
||||||
} finally {
|
|
||||||
parser.close()
|
|
||||||
}
|
|
||||||
return tags
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
|
||||||
fun currentTag(): String? {
|
|
||||||
val locales = AppCompatDelegate.getApplicationLocales()
|
|
||||||
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
|
||||||
fun apply(tag: String?) {
|
|
||||||
val locales = if (tag == null) {
|
|
||||||
LocaleListCompat.getEmptyLocaleList()
|
|
||||||
} else {
|
|
||||||
LocaleListCompat.forLanguageTags(tag)
|
|
||||||
}
|
|
||||||
AppCompatDelegate.setApplicationLocales(locales)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The autonym for a tag — the language's own name in its own script, e.g.
|
|
||||||
* "Deutsch", "English", "Français" — so users find their language regardless
|
|
||||||
* of the current UI language. Capitalised per the language's own rules.
|
|
||||||
*/
|
|
||||||
fun displayName(tag: String): String {
|
|
||||||
val locale = Locale.forLanguageTag(tag)
|
|
||||||
return locale.getDisplayName(locale)
|
|
||||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -59,9 +58,9 @@ import androidx.compose.material.icons.filled.Translate
|
|||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.filled.UploadFile
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -80,6 +79,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -94,43 +94,46 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.AboutCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.AboutLink
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
|
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight
|
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
@@ -158,7 +161,7 @@ private enum class ChipAccent { Neutral, Primary, Tertiary }
|
|||||||
* Settings (M4), restructured in v2.3 into a category hub with sub-screens.
|
* Settings (M4), restructured in v2.3 into a category hub with sub-screens.
|
||||||
* Both the hub and the sub-screens use a collapsing [LargeTopAppBar] and the
|
* Both the hub and the sub-screens use a collapsing [LargeTopAppBar] and the
|
||||||
* grouped-row card system. Calendars opens the separate manager hoisted in
|
* grouped-row card system. Calendars opens the separate manager hoisted in
|
||||||
* [CalendarHost]; Language opens an inline OptionCard dialog; About is a card
|
* [CalendarHost]; Language opens a full-screen picker; About is a card
|
||||||
* at the top. A full-screen destination; [onBack] pops it.
|
* at the top. A full-screen destination; [onBack] pops it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -236,7 +239,7 @@ private fun SettingsHub(
|
|||||||
onOpenSection: (SettingsSection) -> Unit,
|
onOpenSection: (SettingsSection) -> Unit,
|
||||||
onManageCalendars: () -> Unit,
|
onManageCalendars: () -> Unit,
|
||||||
) {
|
) {
|
||||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
|
||||||
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -357,7 +360,7 @@ private fun LanguageRow(position: Position) {
|
|||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
|
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
@@ -370,6 +373,7 @@ private fun LanguageRow(position: Position) {
|
|||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
|
predictiveBack = true,
|
||||||
options = options,
|
options = options,
|
||||||
selected = current,
|
selected = current,
|
||||||
label = { languageLabel(it) },
|
label = { languageLabel(it) },
|
||||||
@@ -396,83 +400,30 @@ private fun LanguageRow(position: Position) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutCard() {
|
private fun AboutCard() {
|
||||||
val context = LocalContext.current
|
// The card layout lives in floret-kit (components.AboutCard); Calendula
|
||||||
val sourceUrl = stringResource(R.string.about_source_url)
|
// supplies its own logo, author and the source / licence / support links.
|
||||||
val licenseUrl = stringResource(R.string.about_license_url)
|
AboutCard(
|
||||||
val supportUrl = stringResource(R.string.about_support_url)
|
logo = { AppLogo() },
|
||||||
|
appName = stringResource(R.string.app_name),
|
||||||
Surface(
|
author = stringResource(R.string.settings_about_author),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
primaryLinks = listOf(
|
||||||
shape = RoundedCornerShape(24.dp),
|
AboutLink(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
icon = ImageVector.vectorResource(R.drawable.ic_gitea),
|
||||||
) {
|
label = stringResource(R.string.settings_about_source),
|
||||||
Column(
|
url = stringResource(R.string.about_source_url),
|
||||||
modifier = Modifier
|
),
|
||||||
.fillMaxWidth()
|
AboutLink(
|
||||||
.padding(16.dp),
|
icon = Icons.Default.Gavel,
|
||||||
) {
|
label = stringResource(R.string.settings_license),
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
url = stringResource(R.string.about_license_url),
|
||||||
AppLogo()
|
),
|
||||||
Spacer(Modifier.width(16.dp))
|
),
|
||||||
Column(Modifier.weight(1f)) {
|
highlightLink = AboutLink(
|
||||||
Text(
|
icon = Icons.Default.Favorite,
|
||||||
text = stringResource(R.string.app_name),
|
label = stringResource(R.string.settings_about_support),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
url = stringResource(R.string.about_support_url),
|
||||||
)
|
),
|
||||||
Text(
|
)
|
||||||
text = stringResource(R.string.settings_about_author),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { openUrl(context, sourceUrl) },
|
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_gitea),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.settings_about_source))
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { openUrl(context, licenseUrl) },
|
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Gavel,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.settings_license))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { openUrl(context, supportUrl) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Favorite,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(stringResource(R.string.settings_about_support))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plain centred version mark at the foot of the settings list (no card). */
|
/** Plain centred version mark at the foot of the settings list (no card). */
|
||||||
@@ -550,6 +501,7 @@ private fun AppearanceScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
// Theme & colour
|
// Theme & colour
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -613,6 +565,18 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
onClick = { showWeekStart = true },
|
onClick = { showWeekStart = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_week_numbers),
|
||||||
|
summary = stringResource(R.string.settings_week_numbers_summary),
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.showWeekNumbers,
|
||||||
|
onCheckedChange = viewModel::setShowWeekNumbers,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_time_format),
|
title = stringResource(R.string.settings_time_format),
|
||||||
summary = timeFormatLabel(state.timeFormat),
|
summary = timeFormatLabel(state.timeFormat),
|
||||||
@@ -667,6 +631,18 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
onClick = { showAgendaWidgetRange = true },
|
onClick = { showAgendaWidgetRange = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_show_today),
|
||||||
|
summary = stringResource(R.string.settings_agenda_show_today_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.agendaShowToday,
|
||||||
|
onCheckedChange = viewModel::setAgendaShowToday,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_agenda_range_bar),
|
title = stringResource(R.string.settings_agenda_range_bar),
|
||||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||||
@@ -684,6 +660,7 @@ private fun AppearanceScreen(
|
|||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
|
predictiveBack = true,
|
||||||
options = ThemeMode.entries,
|
options = ThemeMode.entries,
|
||||||
selected = state.themeMode,
|
selected = state.themeMode,
|
||||||
label = { themeLabel(it) },
|
label = { themeLabel(it) },
|
||||||
@@ -716,6 +693,7 @@ private fun AppearanceScreen(
|
|||||||
if (showWeekStart) {
|
if (showWeekStart) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
|
predictiveBack = true,
|
||||||
options = WEEK_START_OPTIONS,
|
options = WEEK_START_OPTIONS,
|
||||||
selected = state.weekStart,
|
selected = state.weekStart,
|
||||||
label = { weekStartLabel(it) },
|
label = { weekStartLabel(it) },
|
||||||
@@ -744,6 +722,7 @@ private fun AppearanceScreen(
|
|||||||
if (showTimeFormat) {
|
if (showTimeFormat) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_time_format),
|
title = stringResource(R.string.settings_time_format),
|
||||||
|
predictiveBack = true,
|
||||||
options = TimeFormatPref.entries,
|
options = TimeFormatPref.entries,
|
||||||
selected = state.timeFormat,
|
selected = state.timeFormat,
|
||||||
label = { timeFormatLabel(it) },
|
label = { timeFormatLabel(it) },
|
||||||
@@ -764,6 +743,7 @@ private fun AppearanceScreen(
|
|||||||
if (showDefaultView) {
|
if (showDefaultView) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_default_view),
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
predictiveBack = true,
|
||||||
options = IMPLEMENTED_VIEWS,
|
options = IMPLEMENTED_VIEWS,
|
||||||
selected = state.defaultView,
|
selected = state.defaultView,
|
||||||
label = { stringResource(it.labelRes) },
|
label = { stringResource(it.labelRes) },
|
||||||
@@ -904,6 +884,7 @@ private fun EventFormScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_event_form),
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.settings_form_fields_hint),
|
text = stringResource(R.string.settings_form_fields_hint),
|
||||||
@@ -1039,6 +1020,7 @@ private fun NotificationsScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_notifications),
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_reminders),
|
title = stringResource(R.string.settings_reminders),
|
||||||
@@ -1129,8 +1111,8 @@ private fun NotificationsScreen(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = calendarSectionExpanded,
|
visible = calendarSectionExpanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
state.writableCalendars.forEach { calendar ->
|
state.writableCalendars.forEach { calendar ->
|
||||||
@@ -1178,18 +1160,18 @@ private fun NotificationsScreen(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = expanded,
|
visible = expanded,
|
||||||
enter = calendarExpandEnter(),
|
enter = expandEnter(),
|
||||||
exit = calendarCollapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||||
)
|
)
|
||||||
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
|
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_default_reminder_allday),
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||||
@@ -1205,9 +1187,9 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showSnooze) {
|
if (showSnooze) {
|
||||||
OptionPicker(
|
SnoozeDurationPicker(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
options = SNOOZE_PRESETS,
|
presets = SNOOZE_PRESETS,
|
||||||
selected = state.snoozeMinutes,
|
selected = state.snoozeMinutes,
|
||||||
label = { snoozeDurationLabel(it) },
|
label = { snoozeDurationLabel(it) },
|
||||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||||
@@ -1262,7 +1244,7 @@ private fun NotificationsScreen(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||||
selected = map.choiceFor(target.calendarId),
|
selected = map.reminderOverrideFor(target.calendarId),
|
||||||
allowInherit = true,
|
allowInherit = true,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
if (target.isAllDay) {
|
if (target.isAllDay) {
|
||||||
@@ -1451,7 +1433,7 @@ private fun SpecialDatesScreen(
|
|||||||
ReminderDefaultPicker(
|
ReminderDefaultPicker(
|
||||||
title = stringResource(R.string.settings_special_dates_reminders),
|
title = stringResource(R.string.settings_special_dates_reminders),
|
||||||
presets = ALLDAY_REMINDER_PRESETS,
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
selected = state.reminderChoices[type] ?: CalendarReminderOverride.None,
|
selected = state.reminderChoices[type] ?: ReminderOverride.None,
|
||||||
// Managed calendars own their reminders outright — no "inherit global".
|
// Managed calendars own their reminders outright — no "inherit global".
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
||||||
@@ -1461,8 +1443,8 @@ private fun SpecialDatesScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
||||||
private fun specialDatesReminderMinutes(choice: CalendarReminderOverride?): List<Int> =
|
private fun specialDatesReminderMinutes(choice: ReminderOverride?): List<Int> =
|
||||||
(choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
(choice as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SpecialDatesDisableDialog(
|
private fun SpecialDatesDisableDialog(
|
||||||
@@ -1561,12 +1543,12 @@ private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
|||||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||||
|
|
||||||
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||||
private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
|
private fun List<Int>.toReminderChoice(): ReminderOverride =
|
||||||
if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this)
|
||||||
|
|
||||||
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||||
private fun CalendarReminderOverride.toMinutesList(): List<Int> =
|
private fun ReminderOverride.toMinutesList(): List<Int> =
|
||||||
(this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
|
(this as? ReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
@@ -1650,13 +1632,13 @@ private fun reminderChoiceLabel(minutes: List<Int>): String {
|
|||||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun calendarOverrideSummary(
|
private fun calendarOverrideSummary(
|
||||||
choice: CalendarReminderOverride,
|
choice: ReminderOverride,
|
||||||
globalDefault: List<Int>,
|
globalDefault: List<Int>,
|
||||||
): String = when (choice) {
|
): String = when (choice) {
|
||||||
CalendarReminderOverride.Inherit ->
|
ReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
@@ -33,10 +33,14 @@ data class SettingsUiState(
|
|||||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||||
/** Whether the month/week grids fade events that have already finished. */
|
/** Whether the month/week grids fade events that have already finished. */
|
||||||
val dimCompletedEvents: Boolean = false,
|
val dimCompletedEvents: Boolean = false,
|
||||||
|
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
||||||
|
val showWeekNumbers: Boolean = false,
|
||||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||||
|
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
|
||||||
|
val agendaShowToday: Boolean = true,
|
||||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||||
val agendaShowRangeBar: Boolean = true,
|
val agendaShowRangeBar: Boolean = true,
|
||||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||||
@@ -96,7 +100,7 @@ data class SpecialDatesUiState(
|
|||||||
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
||||||
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||||
/** The reminder choice per type's managed calendar (applied to all its events). */
|
/** The reminder choice per type's managed calendar (applied to all its events). */
|
||||||
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
|
val reminderChoices: Map<SpecialDateType, ReminderOverride> = emptyMap(),
|
||||||
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
||||||
val showYear: Boolean = true,
|
val showYear: Boolean = true,
|
||||||
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||||
@@ -37,6 +37,7 @@ import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
|||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -116,9 +117,21 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.agendaScreenRange,
|
prefs.agendaScreenRange,
|
||||||
prefs.agendaWidgetRange,
|
prefs.agendaWidgetRange,
|
||||||
prefs.timeFormat,
|
prefs.timeFormat,
|
||||||
prefs.showHourLines,
|
// Display toggles folded into one flow so they fit this group —
|
||||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
// the outer combine is already at its five-arg limit.
|
||||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
combine(
|
||||||
|
prefs.showHourLines,
|
||||||
|
prefs.showWeekNumbers,
|
||||||
|
prefs.agendaShowToday,
|
||||||
|
::Triple,
|
||||||
|
),
|
||||||
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
|
ViewSettings(
|
||||||
|
view, screenRange, widgetRange, timeFormat,
|
||||||
|
showHourLines = toggles.first,
|
||||||
|
showWeekNumbers = toggles.second,
|
||||||
|
agendaShowToday = toggles.third,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.agendaShowRangeBar,
|
prefs.agendaShowRangeBar,
|
||||||
@@ -140,6 +153,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
agendaWidgetRange = views.agendaWidgetRange,
|
agendaWidgetRange = views.agendaWidgetRange,
|
||||||
timeFormat = views.timeFormat,
|
timeFormat = views.timeFormat,
|
||||||
showHourLines = views.showHourLines,
|
showHourLines = views.showHourLines,
|
||||||
|
showWeekNumbers = views.showWeekNumbers,
|
||||||
|
agendaShowToday = views.agendaShowToday,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
@@ -212,6 +227,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val agendaWidgetRange: AgendaRange,
|
val agendaWidgetRange: AgendaRange,
|
||||||
val timeFormat: TimeFormatPref,
|
val timeFormat: TimeFormatPref,
|
||||||
val showHourLines: Boolean,
|
val showHourLines: Boolean,
|
||||||
|
val showWeekNumbers: Boolean,
|
||||||
|
val agendaShowToday: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -255,7 +272,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
base.copy(
|
base.copy(
|
||||||
// An override is always seeded on creation; present-empty = None.
|
// An override is always seeded on creation; present-empty = None.
|
||||||
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
||||||
allDayOverrides.choiceFor(calendarId)
|
allDayOverrides.reminderOverrideFor(calendarId)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
@@ -309,7 +326,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
* Set a type's managed-calendar reminder default and apply it to all its
|
* Set a type's managed-calendar reminder default and apply it to all its
|
||||||
* existing events (managed calendars own their reminders calendar-wide).
|
* existing events (managed calendars own their reminders calendar-wide).
|
||||||
*/
|
*/
|
||||||
fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
fun setSpecialDatesReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||||
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
|
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +407,22 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAgendaShowToday(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setAgendaShowToday(enabled)
|
||||||
|
// The agenda widget reads this reactively, so push it into each
|
||||||
|
// instance's Glance state and recompose — same reliable-update path as
|
||||||
|
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
|
||||||
|
widgetRefreshMutex.withLock {
|
||||||
|
val manager = GlanceAppWidgetManager(appContext)
|
||||||
|
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||||
|
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
|
||||||
|
}
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setTimeFormat(pref: TimeFormatPref) {
|
fun setTimeFormat(pref: TimeFormatPref) {
|
||||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||||
}
|
}
|
||||||
@@ -398,6 +431,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setShowWeekNumbers(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setPastEventDisplay(mode)
|
prefs.setPastEventDisplay(mode)
|
||||||
@@ -476,11 +513,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||||
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||||
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.theme
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
|
||||||
import androidx.compose.material3.MotionScheme
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App theme. Honors:
|
* Calendula's theme: the family's [FloretExpressiveTheme] machinery — dynamic
|
||||||
* - System light/dark.
|
* colour on API 31+, system light/dark, and the **standard** motion scheme
|
||||||
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
* (spring choreography without the overshoot) — fed with Calendula's own
|
||||||
* derived from [CalendulaSeed].
|
* identity: its seed-derived fallback schemes ([CalendulaLightFallback] /
|
||||||
* - A user-chosen [typography] (custom fonts, issue #19), defaulting to the
|
* [CalendulaDarkFallback]) and a [typography] that defaults to
|
||||||
* Material scale on the system typeface.
|
* [CalendulaTypography] but is overridable for the user's custom-font choice
|
||||||
|
* (issue #19).
|
||||||
*
|
*
|
||||||
* The Settings screen overrides darkTheme, dynamicColor, and typography; the
|
* The mechanics live in floret-kit's identity module; the look stays here, so a
|
||||||
* bare defaults (used by the crash screen) just follow the system.
|
* sibling app reuses the same theming without inheriting Calendula's palette.
|
||||||
|
* The Settings screen overrides darkTheme, dynamicColor and typography; the bare
|
||||||
|
* defaults (used by the crash screen) just follow the system.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendulaTheme(
|
fun CalendulaTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
@@ -30,24 +26,12 @@ fun CalendulaTheme(
|
|||||||
typography: Typography = CalendulaTypography,
|
typography: Typography = CalendulaTypography,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
FloretExpressiveTheme(
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
lightScheme = CalendulaLightFallback,
|
||||||
val ctx = LocalContext.current
|
darkScheme = CalendulaDarkFallback,
|
||||||
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
|
darkTheme = darkTheme,
|
||||||
}
|
dynamicColor = dynamicColor,
|
||||||
darkTheme -> CalendulaDarkFallback
|
|
||||||
else -> CalendulaLightFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaterialExpressiveTheme routes all component + custom motion through
|
|
||||||
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
|
|
||||||
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
|
|
||||||
// expressive(): same spring choreography, but without the overshoot —
|
|
||||||
// the bouncy variant felt overdone in review (2026-06-11).
|
|
||||||
MaterialExpressiveTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
typography = typography,
|
typography = typography,
|
||||||
motionScheme = MotionScheme.standard(),
|
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
@@ -98,12 +98,14 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
@@ -112,6 +114,10 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
private val HOUR_HEIGHT = 56.dp
|
||||||
private val GUTTER_WIDTH = 48.dp
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||||
|
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
||||||
|
* (the app bar's 4dp inset + 24dp half icon button). */
|
||||||
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
private val MIN_EVENT_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
@@ -439,19 +445,17 @@ private fun WeekDayHeader(
|
|||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val weekStart = days.first()
|
val weekStart = days.first()
|
||||||
val weekNumber = remember(weekStart) {
|
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||||
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
|
|
||||||
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp, bottom = 8.dp),
|
.padding(top = 4.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||||
// badge lines up vertically with the date numbers.
|
// badge lines up vertically with the date numbers. The start inset centres
|
||||||
|
// the badge on the top bar's hamburger (see GUTTER_CONTENT_START_INSET).
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.width(GUTTER_WIDTH),
|
modifier = Modifier.width(GUTTER_WIDTH).padding(start = GUTTER_CONTENT_START_INSET),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
Text(text = " ", style = MaterialTheme.typography.labelSmall)
|
||||||
@@ -622,10 +626,12 @@ private fun Timeline(
|
|||||||
// soft corners are permanent at any scroll position (not just at the
|
// soft corners are permanent at any scroll position (not just at the
|
||||||
// day's start/end).
|
// day's start/end).
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
// Hour gutter (scrolls in sync with the day columns)
|
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||||
|
// as the header badge so the labels sit under it and on the hamburger.
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(GUTTER_WIDTH)
|
.width(GUTTER_WIDTH)
|
||||||
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ sealed interface AgendaWidgetData {
|
|||||||
val savedRange: AgendaRange,
|
val savedRange: AgendaRange,
|
||||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||||
val savedPastDisplay: PastEventDisplay,
|
val savedPastDisplay: PastEventDisplay,
|
||||||
|
/**
|
||||||
|
* Saved "always show today" pref (#35) — anchors today at the top with a
|
||||||
|
* placeholder even when it has no events left. The fallback read reactively
|
||||||
|
* from Glance state before an instance has its own state set.
|
||||||
|
*/
|
||||||
|
val savedShowToday: Boolean,
|
||||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||||
val now: Instant,
|
val now: Instant,
|
||||||
) : AgendaWidgetData
|
) : AgendaWidgetData
|
||||||
@@ -117,6 +123,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
val prefs = ep.settingsPrefs()
|
val prefs = ep.settingsPrefs()
|
||||||
val savedRange = prefs.agendaWidgetRange.first()
|
val savedRange = prefs.agendaWidgetRange.first()
|
||||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||||
|
val showToday = prefs.agendaShowToday.first()
|
||||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
// Load the widest selectable window once; the displayed range is sliced in
|
// Load the widest selectable window once; the displayed range is sliced in
|
||||||
// the composition from Glance state, so changing the range is a plain
|
// the composition from Glance state, so changing the range is a plain
|
||||||
@@ -132,6 +139,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
savedRange = savedRange,
|
savedRange = savedRange,
|
||||||
savedPastDisplay = savedPastDisplay,
|
savedPastDisplay = savedPastDisplay,
|
||||||
|
savedShowToday = showToday,
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
@@ -48,12 +49,14 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.localizedDateFormatter
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
@@ -65,7 +68,6 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,6 +93,14 @@ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
|||||||
*/
|
*/
|
||||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-instance Glance state key holding the "always show today" toggle (#35).
|
||||||
|
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
|
||||||
|
* so toggling the setting reflects on the live widget without depending on the
|
||||||
|
* `provideGlance` preamble re-running.
|
||||||
|
*/
|
||||||
|
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
|
||||||
|
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
@@ -118,6 +128,8 @@ class RefreshAgendaAction : ActionCallback {
|
|||||||
private sealed interface AgendaRow {
|
private sealed interface AgendaRow {
|
||||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||||
data class Event(val event: EventInstance) : AgendaRow
|
data class Event(val event: EventInstance) : AgendaRow
|
||||||
|
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
||||||
|
data class Placeholder(val date: LocalDate) : AgendaRow
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -139,37 +151,49 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||||
val pastDisplay =
|
val pastDisplay =
|
||||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||||
|
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
||||||
val rangeEnd = data.today.plus(
|
val rangeEnd = data.today.plus(
|
||||||
range.dayCount(data.today, data.weekStart) - 1,
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
// Slice to the chosen range, then drop finished events (and any day
|
// Slice to the chosen range, then drop finished events (and any day
|
||||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||||
val visibleDays = data.days
|
// Finally re-anchor today (as an empty placeholder day) when the
|
||||||
.filter { it.date <= rangeEnd }
|
// "always show today" pref is on and today has no events left (#35).
|
||||||
.let { days ->
|
val visibleDays = anchorTodayIfMissing(
|
||||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
days = data.days
|
||||||
days.mapNotNull { day ->
|
.filter { it.date <= rangeEnd }
|
||||||
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
.let { days ->
|
||||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
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
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
days
|
today = data.today,
|
||||||
}
|
enabled = showToday,
|
||||||
}
|
)
|
||||||
if (visibleDays.isEmpty()) {
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title)
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
visibleDays.forEach { day ->
|
visibleDays.forEach { day ->
|
||||||
add(AgendaRow.Header(day.date, data.today))
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
if (day.events.isEmpty()) {
|
||||||
|
add(AgendaRow.Placeholder(day.date))
|
||||||
|
} else {
|
||||||
|
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||||
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date)
|
||||||
is AgendaRow.Event -> EventRow(
|
is AgendaRow.Event -> EventRow(
|
||||||
event = row.event,
|
event = row.event,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
@@ -262,6 +286,28 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "No more events today" under an anchored, event-less today (#35). Kept plain —
|
||||||
|
* a muted line indented to the event titles — since the widget's event rows are
|
||||||
|
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PlaceholderRow(date: LocalDate) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.agenda_no_more_today),
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 19.dp, end = 8.dp, top = 2.dp, bottom = 6.dp)
|
||||||
|
.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
@@ -337,9 +383,9 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
|||||||
}
|
}
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
// Weekday + date in the locale's own field order (compact: no year), rather
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
// than a hardcoded day-month layout.
|
||||||
val formatted = "$weekday, ${date.day} $monthName"
|
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
|
||||||
return if (relative != null) "$relative · $formatted" else formatted
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import de.jeanlucmakiola.calendula.MainActivity
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
|||||||
@@ -220,7 +220,7 @@
|
|||||||
<string name="search_action">Suchen</string>
|
<string name="search_action">Suchen</string>
|
||||||
<string name="search_hint">Termine suchen</string>
|
<string name="search_hint">Termine suchen</string>
|
||||||
<string name="search_back">Zurück</string>
|
<string name="search_back">Zurück</string>
|
||||||
<string name="search_clear">Löschen</string>
|
<string name="search_clear">Leeren</string>
|
||||||
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
||||||
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
||||||
<!-- Startbildschirm-Widgets -->
|
<!-- Startbildschirm-Widgets -->
|
||||||
@@ -245,6 +245,7 @@
|
|||||||
<!-- Einstellungen (M4) -->
|
<!-- Einstellungen (M4) -->
|
||||||
<string name="settings_title">Einstellungen</string>
|
<string name="settings_title">Einstellungen</string>
|
||||||
<string name="settings_back">Zurück</string>
|
<string name="settings_back">Zurück</string>
|
||||||
|
<string name="back">Zurück</string>
|
||||||
<string name="settings_section_appearance">Darstellung</string>
|
<string name="settings_section_appearance">Darstellung</string>
|
||||||
<string name="settings_theme">Design</string>
|
<string name="settings_theme">Design</string>
|
||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
@@ -309,7 +310,7 @@
|
|||||||
<string name="settings_language">App-Sprache</string>
|
<string name="settings_language">App-Sprache</string>
|
||||||
<string name="settings_language_auto">Systemstandard</string>
|
<string name="settings_language_auto">Systemstandard</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Design, Standartansicht, Wochenstart</string>
|
<string name="settings_appearance_subtitle">Design, Standardansicht, Wochenstart</string>
|
||||||
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
<string name="settings_event_form_subtitle">Standardfelder für neue Termine</string>
|
||||||
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
<string name="settings_notifications_subtitle">Termin-Erinnerungen</string>
|
||||||
<string name="settings_section_about">Über</string>
|
<string name="settings_section_about">Über</string>
|
||||||
@@ -329,7 +330,7 @@
|
|||||||
<string name="calendars_add">Kalender hinzufügen</string>
|
<string name="calendars_add">Kalender hinzufügen</string>
|
||||||
<string name="calendars_synced_header">Synchronisierte Kalender</string>
|
<string name="calendars_synced_header">Synchronisierte Kalender</string>
|
||||||
<string name="calendars_synced_hint">Diese stammen von Konten auf deinem Gerät. Erstelle und bearbeite sie in der jeweiligen App.</string>
|
<string name="calendars_synced_hint">Diese stammen von Konten auf deinem Gerät. Erstelle und bearbeite sie in der jeweiligen App.</string>
|
||||||
<string name="calendars_manage_in_app">Verwalten</string>
|
<string name="calendars_manage_in_app">In der App verwalten</string>
|
||||||
<string name="calendars_add_account">Konto hinzufügen</string>
|
<string name="calendars_add_account">Konto hinzufügen</string>
|
||||||
<string name="calendars_new_title">Neuer Kalender</string>
|
<string name="calendars_new_title">Neuer Kalender</string>
|
||||||
<string name="calendars_edit_title">Kalender bearbeiten</string>
|
<string name="calendars_edit_title">Kalender bearbeiten</string>
|
||||||
@@ -377,15 +378,15 @@
|
|||||||
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
|
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
||||||
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
|
<string name="crash_dialog_title">%1$s ist abgestürzt</string>
|
||||||
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
<string name="crash_dialog_message">%1$s wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
||||||
<string name="crash_dialog_report">Melden</string>
|
<string name="crash_dialog_report">Melden</string>
|
||||||
<string name="crash_dialog_dismiss">Nicht jetzt</string>
|
<string name="crash_dialog_dismiss">Nicht jetzt</string>
|
||||||
<string name="crash_report_issue_title">Absturzbericht</string>
|
<string name="crash_report_issue_title">Absturzbericht</string>
|
||||||
<string name="crash_report_clip_label">Calendula-Absturzbericht</string>
|
<string name="crash_report_clip_label">%1$s-Absturzbericht</string>
|
||||||
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
|
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
|
||||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
|
<string name="crash_report_body_template">Danke, dass du einen Absturz in %1$s meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%2$s\n</string>
|
||||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||||
<string name="event_edit_managed_hint">Verwaltet durch “%1$s” — der Titel, das Datum und Wiederholungen werden synchron mit deinen Kontakten gehalten. Erinnerungen, Ort und Beschreibung kannst du frei bearbeiten.</string>
|
<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">
|
<plurals name="duration_days">
|
||||||
@@ -393,10 +394,10 @@
|
|||||||
<item quantity="other">%d Tage</item>
|
<item quantity="other">%d Tage</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<plurals name="duration_weeks">
|
<plurals name="duration_weeks">
|
||||||
<item quantity="one">Woche</item>
|
<item quantity="one">%d Woche</item>
|
||||||
<item quantity="other">Wochen</item>
|
<item quantity="other">%d Wochen</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_default_view">Standartansicht</string>
|
<string name="settings_default_view">Standardansicht</string>
|
||||||
<string name="settings_font_headings">Überschriften Schriftart</string>
|
<string name="settings_font_headings">Überschriften Schriftart</string>
|
||||||
<string name="settings_font_system">Systemstandard</string>
|
<string name="settings_font_system">Systemstandard</string>
|
||||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||||
@@ -410,4 +411,83 @@
|
|||||||
<string name="settings_past_events_hide">Ausblenden</string>
|
<string name="settings_past_events_hide">Ausblenden</string>
|
||||||
<string name="settings_agenda_header">Agenda</string>
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
<string name="settings_special_dates_reminders">Erinnerungen</string>
|
<string name="settings_special_dates_reminders">Erinnerungen</string>
|
||||||
|
<string name="settings_font_body">Inhaltsschriftart</string>
|
||||||
|
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||||
|
<string name="font_lora">Lora</string>
|
||||||
|
<string name="settings_font_custom_selected">Benutzerdefinierte Schriftart</string>
|
||||||
|
<string name="settings_section_views">Ansichten</string>
|
||||||
|
<string name="settings_quick_switch_header">Schnellwechselknopf</string>
|
||||||
|
<string name="settings_quick_switch_hint">Wähle aus, durch welche Ansichten du mit dem Knopf oben rechts wechseln möchtest, und ziehe, um sie neu anzuordnen. Deaktivierte Ansichten bleiben über das Navigationsmenü erreichbar.</string>
|
||||||
|
<string name="settings_drawer_order_header">Navigationsmenü</string>
|
||||||
|
<string name="settings_drawer_order_hint">Ziehen, um die im Navigationsmenü aufgelisteten Ansichten neu anzuordnen.</string>
|
||||||
|
<string name="reorder_drag_handle">Zum Neuanordnen ziehen</string>
|
||||||
|
<string name="settings_calendar_reminders_title">Erinnerungen pro Kalender</string>
|
||||||
|
<string name="settings_translate">Hilf beim Übersetzen</string>
|
||||||
|
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
||||||
|
<string name="settings_views_subtitle">Schnellwechsellnopf und Menüreihenfolge</string>
|
||||||
|
<string name="settings_special_dates_subtitle">Kontakte-Geburtstage und Jubiläen</string>
|
||||||
|
<string name="settings_section_special_dates">Besondere Termine von Kontakten</string>
|
||||||
|
<string name="settings_special_dates_enable">Kontakttermine anzeigen</string>
|
||||||
|
<string name="settings_special_dates_enable_hint">Spiegle die Geburtstage und andere Termine deiner Kontakte in lokale Kalender. Liest nur Kontakte auf diesem Gerät – es wird nichts hochgeladen und Ihre Kontakte werden nie geändert.</string>
|
||||||
|
<string name="settings_special_dates_type_birthday">Geburtstage</string>
|
||||||
|
<string name="settings_special_dates_type_anniversary">Jahrestage</string>
|
||||||
|
<string name="settings_special_dates_type_custom">Andere Ereignisse</string>
|
||||||
|
<string name="settings_special_dates_template">Titelformat</string>
|
||||||
|
<string name="settings_special_dates_template_hint">Verwende {Name} für den Kontakt und {Jahr} für das Jahr (das Geburtsjahr oder das Anfangsjahr eines Jahrestages; versteckt, wenn unbekannt).</string>
|
||||||
|
<string name="settings_special_dates_show_year">Jahr anzeigen</string>
|
||||||
|
<string name="settings_special_dates_show_year_hint">{year} in Titel einfügen, wenn bekannt</string>
|
||||||
|
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
|
||||||
|
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
|
||||||
|
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
|
||||||
|
<string name="settings_special_dates_calendar_hint">Lege die Farbe und Sichtbarkeit jedes Kalenders in den Kalendereinstellungen fest.</string>
|
||||||
|
<string name="settings_special_dates_paused_title">Pausiert</string>
|
||||||
|
<string name="settings_calendar_reminders_managed_hint">In den besonderen Terminen der Kontakte setzen</string>
|
||||||
|
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
|
||||||
|
<string name="settings_special_dates_disable_title">Kontakttermine deaktivieren?</string>
|
||||||
|
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontaktterminkalender und ihre Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
|
||||||
|
<string name="settings_special_dates_grant">Zugriff gewähren</string>
|
||||||
|
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
|
||||||
|
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
||||||
|
<string name="dialog_save">Speichern</string>
|
||||||
|
<string name="calendars_disable_hint">Deaktiviere einen Kalender, um ihn aus der App zu entfernen – seine Ereignisse, Filter und Auswahlmöglichkeiten. Es wird nichts gelöscht und du kannst ihn hier jederzeit wieder aktivieren.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">„%1$s“ in der App anzeigen</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Weitere Optionen für %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Alle aktivieren</string>
|
||||||
|
<string name="calendars_disable_all">Alle deaktivieren</string>
|
||||||
|
<string name="calendars_auto_backup">Automatische Sicherung</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Exportiere deine lokalen Kalender regelmäßig als .ics-Datei in einen Ordner.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Sicherungsordner</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Tippe, um einen Ordner auszuwählen</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Intervall</string>
|
||||||
|
<string name="calendars_auto_backup_every">Alle %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Mindestens 30 Minuten.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">Noch keine automatische Sicherung</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Letzte Sicherung: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Letzte Sicherung fehlgeschlagen: %1$s</string>
|
||||||
|
<string name="backup_channel_name">Sicherung</string>
|
||||||
|
<string name="backup_channel_description">Warnt, wenn automatische Sicherungen wiederholt fehlschlagen.</string>
|
||||||
|
<string name="backup_failed_title">Automatische Sicherung fehlgeschlagen</string>
|
||||||
|
<string name="backup_failed_text">Calendula konnte die Sicherungsdatei nicht schreiben. Überprüfe den Sicherungsordner in den Einstellungen.</string>
|
||||||
|
<string name="special_dates_calendar_birthday">Geburtstage</string>
|
||||||
|
<string name="special_dates_calendar_anniversary">Jahrestage</string>
|
||||||
|
<string name="special_dates_calendar_custom">Besondere Termine</string>
|
||||||
|
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
|
||||||
|
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
|
<string name="settings_week_numbers">Kalenderwochen</string>
|
||||||
|
<string name="settings_week_numbers_summary">Kalenderwochen der Monatsansicht anzeigen</string>
|
||||||
|
<string name="calendars_export_title">Kalender exportieren</string>
|
||||||
|
<string name="calendars_export_hint">Wähle aus, welche Kalender in die .ics-Datei aufgenommen werden sollen.</string>
|
||||||
|
<string name="calendars_export_action">Exportieren</string>
|
||||||
|
<string name="calendars_restore_header">Wiederherstellen</string>
|
||||||
|
<string name="calendars_restore_action">Aus .ics-Datei wiederherstellen</string>
|
||||||
|
<string name="calendars_restore_hint">Importiere Ereignisse aus einem Backup oder einer anderen Kalender-App.</string>
|
||||||
|
<string name="import_done_dedup_note">Bereits im Kalender vorhandene Ereignisse wurden übersprungen.</string>
|
||||||
|
<string name="import_done_added_label">Hinzugefügt</string>
|
||||||
|
<string name="import_done_skipped_label">Duplikate</string>
|
||||||
|
<string name="import_button">Importieren</string>
|
||||||
|
<plurals name="import_title_count">
|
||||||
|
<item quantity="one">%d Ereignis wird importiert</item>
|
||||||
|
<item quantity="other">%d Ereignisse werden importiert</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -403,15 +403,15 @@
|
|||||||
<string name="shortcut_new_event_long">Crear un nuevo evento</string>
|
<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">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="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_title">%1$s 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_message">%1$s 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_report">Reportar</string>
|
||||||
<string name="crash_dialog_dismiss">No por ahora</string>
|
<string name="crash_dialog_dismiss">No por ahora</string>
|
||||||
<string name="crash_report_issue_title">Reporte de error</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_clip_label">Reporte de error de %1$s</string>
|
||||||
<string name="crash_report_copied">Reporte copiado a tu porta papeles</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_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_template">Gracias por reportar un error en %1$s. 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%2$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="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="event_attendee_resource">Recurso</string>
|
||||||
<string name="settings_section_about">Acerca de</string>
|
<string name="settings_section_about">Acerca de</string>
|
||||||
@@ -466,4 +466,31 @@
|
|||||||
<string name="settings_special_dates_grant">Permitir acceso</string>
|
<string name="settings_special_dates_grant">Permitir acceso</string>
|
||||||
<string name="dialog_save">Guardar</string>
|
<string name="dialog_save">Guardar</string>
|
||||||
<string name="settings_font_system">Igual que el sistema</string>
|
<string name="settings_font_system">Igual que el sistema</string>
|
||||||
|
<string name="import_reminder_prompt_title">¿Aplicar tus recordatorios por defecto?</string>
|
||||||
|
<string name="import_reminder_prompt_body_none">Este evento fue importado sin ningún recordatorio.</string>
|
||||||
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
|
<item quantity="one">Este evento fue importado con %1$d recordatorio.</item>
|
||||||
|
<item quantity="many">Este evento fue portado con %1$d recordatorios.</item>
|
||||||
|
<item quantity="other">Este evento fue importado con %1$d recordatorios.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_reminder_prompt_apply">Aplicar por defecto</string>
|
||||||
|
<string name="import_reminder_prompt_keep">Mantener como esta</string>
|
||||||
|
<string name="back">Atras</string>
|
||||||
|
<string name="settings_week_numbers">Números de semana</string>
|
||||||
|
<string name="settings_week_numbers_summary">Muestra los números de las semanas en la vista mensual</string>
|
||||||
|
<string name="calendars_export_title">Exportar calendarios</string>
|
||||||
|
<string name="calendars_export_hint">Escoge que calendarios incluir en el archivo .ics</string>
|
||||||
|
<string name="calendars_export_action">Exportar</string>
|
||||||
|
<string name="calendars_restore_header">Restaurar</string>
|
||||||
|
<string name="calendars_restore_action">Restaurar por archivo .ics</string>
|
||||||
|
<string name="calendars_restore_hint">Importar eventos desde un respaldo u otra aplicación de calendario.</string>
|
||||||
|
<string name="import_done_dedup_note">Se omitieron los eventos ya presentes en el calendario.</string>
|
||||||
|
<string name="import_done_added_label">Añadido</string>
|
||||||
|
<string name="import_done_skipped_label">Duplicados</string>
|
||||||
|
<string name="import_button">Importar</string>
|
||||||
|
<plurals name="import_title_count">
|
||||||
|
<item quantity="one">Importando %d evento</item>
|
||||||
|
<item quantity="many">Importando %d eventos</item>
|
||||||
|
<item quantity="other">Importando %d eventos</item>
|
||||||
|
</plurals>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,4 +6,151 @@
|
|||||||
<item>Days</item>
|
<item>Days</item>
|
||||||
<item>Weeks</item>
|
<item>Weeks</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
<string name="app_name">Calendrier</string>
|
||||||
|
<string name="app_tagline">Un calendrier moderne.</string>
|
||||||
|
<string name="state_loading">Chargement…</string>
|
||||||
|
<string name="state_retry">Réessayer</string>
|
||||||
|
<string name="state_failure_unknown">Une erreur s\'est produit.</string>
|
||||||
|
<string name="state_failure_permission">Un accès au Calendrier est nécessaire.</string>
|
||||||
|
<string name="state_failure_permission_action">Autoriser l\'accès</string>
|
||||||
|
<string name="state_failure_no_calendars">Aucun calendrier configuré.</string>
|
||||||
|
<string name="state_failure_no_calendars_action">Ouvrir les paramètres du calendrier.</string>
|
||||||
|
<string name="state_failure_provider">Ne peut pas lire le calendrier.</string>
|
||||||
|
<string name="permission_rationale_title">Tous les évènements ont été trouvés.</string>
|
||||||
|
<string name="permission_rationale_body">Calendula a besoin d\'avoir accès à votre calendrier pour afficher et accéder aux événements. C\'est tout ce dont nous avons besoin, aucune données ne sort de votre appareil.</string>
|
||||||
|
<string name="permission_request_button">Autoriser l\'accès au calendrier</string>
|
||||||
|
<string name="permission_denied_title">Accès au calendrier refusé.</string>
|
||||||
|
<string name="permission_denied_body">Calendula ne peut pas afficher les évènements si elle ne peut pas accéder au calendrier. Vous pouvez l\'autoriser à tout moment dans les paramètres.</string>
|
||||||
|
<string name="permission_open_settings_button">Ouvrir les paramètres.</string>
|
||||||
|
<string name="permission_retry_button">Essayer à nouveau</string>
|
||||||
|
<string name="permission_benefit_private_title">Reste sur votre appareil</string>
|
||||||
|
<string name="permission_benefit_private_body">Vos calendriers sont lus localement et ne quittent jamais le téléphone.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Tous vos calendriers, réunis</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Aucun suivi, jamais</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Aucune collecte de données, aucune analyse, pas de pubs</string>
|
||||||
|
<string name="permission_privacy_footnote">Reste sur votre appareil - Pas d\'utilisation d\'Internet</string>
|
||||||
|
<string name="month_prev">Mois précédent</string>
|
||||||
|
<string name="month_next">Prochain mois</string>
|
||||||
|
<string name="month_today_action">Aujourd\'hui</string>
|
||||||
|
<string name="month_more_actions">Plus d\'actions</string>
|
||||||
|
<string name="month_open_menu">Ouvrir le menu</string>
|
||||||
|
<string name="month_action_settings">Paramètres</string>
|
||||||
|
<string name="month_a11y_today_prefix">Aujourd\'hui</string>
|
||||||
|
<string name="week_today_action">Cette semaine</string>
|
||||||
|
<string name="day_today_action">Aujourd\'hui</string>
|
||||||
|
<string name="event_detail_back">Retour</string>
|
||||||
|
<string name="event_detail_edit">Modifier</string>
|
||||||
|
<string name="event_detail_delete">Supprimer</string>
|
||||||
|
<string name="event_detail_share">Partager</string>
|
||||||
|
<string name="event_share_chooser_title">Evénement partagé</string>
|
||||||
|
<string name="event_share_failed">Impossible de partager cet événement.</string>
|
||||||
|
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||||
|
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
|
||||||
|
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
|
||||||
|
<string name="event_delete_option_occurrence">Seulement cet événement</string>
|
||||||
|
<string name="event_delete_option_following">Cet événement et tous ceux qui suivent</string>
|
||||||
|
<string name="event_delete_option_series">Tous les événements de la série</string>
|
||||||
|
<string name="event_edit_recurring_title">Modifier l\'événement récurrent</string>
|
||||||
|
<string name="event_delete_failed">Impossible de supprimer l\'événement</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula a besoin des droits d\'écriture pour supprimer des événements</string>
|
||||||
|
<string name="dialog_cancel">Annuler</string>
|
||||||
|
<string name="dialog_ok">OK</string>
|
||||||
|
<string name="event_edit_new_title">Nouvel événement</string>
|
||||||
|
<string name="event_edit_close">Fermer</string>
|
||||||
|
<string name="event_edit_save">Sauvegarder</string>
|
||||||
|
<string name="event_edit_title_hint">Ajouter un titre</string>
|
||||||
|
<string name="event_edit_managed_hint">dirigé par \"%1$s\" - titre, date, et répétitions sont synchronisés avec les contacts. Pour rappel, vous pouvez modifier la localisation et les notes à tout instant.</string>
|
||||||
|
<string name="event_edit_starts">Début</string>
|
||||||
|
<string name="event_edit_ends">Fin</string>
|
||||||
|
<string name="event_edit_error_end_before_start">La fin commence avant le début</string>
|
||||||
|
<string name="event_edit_error_no_calendar">Aucun calendrier n\'est disponible en écriture</string>
|
||||||
|
<string name="event_edit_save_failed">Impossible d\'enregistrer l\'événement</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula a besoin des droits d\'écriture pour créer un événement</string>
|
||||||
|
<string name="event_edit_more_fields">Plus de champs</string>
|
||||||
|
<string name="event_edit_add">Ajouter</string>
|
||||||
|
<string name="event_edit_add_reminder">Ajouter un rappel</string>
|
||||||
|
<string name="event_edit_remove_reminder">Supprimer le rappel</string>
|
||||||
|
<string name="event_edit_attendees">Invités</string>
|
||||||
|
<string name="event_edit_add_guest">Ajouter un invité</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Ajouter un invité à partir d\'un email…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Ajouter à partir des contacts</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Sélectionner une adresse dans les contacts</string>
|
||||||
|
<string name="event_edit_remove_guest">Retirer un invité</string>
|
||||||
|
<string name="event_edit_attendee_required">Requis</string>
|
||||||
|
<string name="event_edit_attendee_optional">Optionnel</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula n\'envoie pas d\'invitations. Votre calendrier par défaut peut envoyer des e-mails aux invités lors de la synchronisation.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Stocké sur cet appareil. Personne d\'autres n\'y a accès.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Personnaliser</string>
|
||||||
|
<string name="reminder_unit_minutes">minutes</string>
|
||||||
|
<string name="reminder_unit_hours">heures</string>
|
||||||
|
<string name="reminder_unit_days">jours</string>
|
||||||
|
<string name="reminder_unit_weeks">semaines</string>
|
||||||
|
<string name="event_edit_availability">Disponibilité</string>
|
||||||
|
<string name="event_edit_visibility">Visibilité</string>
|
||||||
|
<string name="event_edit_color">Couleur</string>
|
||||||
|
<string name="event_edit_color_default">Couleur du calendrier</string>
|
||||||
|
<string name="event_edit_color_custom">Personnaliser la couleur</string>
|
||||||
|
<string name="event_edit_color_reset">Réinitialiser</string>
|
||||||
|
<string name="event_edit_color_unsupported">Non disponible pour ce calendrier</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
|
||||||
|
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
|
||||||
|
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
|
||||||
|
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">L’événement reste tel qu’il est maintenant</string>
|
||||||
|
<string name="event_edit_gone_title">Evénement supprimé</string>
|
||||||
|
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
|
||||||
|
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||||
|
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
|
||||||
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
|
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
|
||||||
|
<item quantity="many">Cet événement a été importé avec %1$d rappels.</item>
|
||||||
|
<item quantity="other">Cet événement a été importé avec %1$d rappels.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_reminder_prompt_apply">Appliquer par défaut</string>
|
||||||
|
<string name="import_reminder_prompt_keep">Garder tel quel</string>
|
||||||
|
<string name="event_edit_recurrence_none">Pas de répétitions</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Personnaliser</string>
|
||||||
|
<string name="event_edit_recurrence_every">Chaque</string>
|
||||||
|
<string name="recurrence_unit_days">jours</string>
|
||||||
|
<string name="recurrence_unit_weeks">semaines</string>
|
||||||
|
<string name="recurrence_unit_months">mois</string>
|
||||||
|
<string name="recurrence_unit_years">années</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Fin</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Jamais</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">A une date</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Après un nombre de fois</string>
|
||||||
|
<string name="event_edit_recurrence_times">fois</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">Les répétitions s\'arrêtent avant le début de l\'événement</string>
|
||||||
|
<string name="event_availability_busy">Occupé</string>
|
||||||
|
<string name="event_access_default">Par défaut</string>
|
||||||
|
<string name="event_access_public">Publique</string>
|
||||||
|
<string name="event_detail_all_day">Journée entière</string>
|
||||||
|
<string name="event_detail_calendar">Calendrier</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Calendrier non reconnu</string>
|
||||||
|
<string name="event_detail_location">Lieux</string>
|
||||||
|
<string name="event_detail_description">Description</string>
|
||||||
|
<string name="event_detail_attendees">participants</string>
|
||||||
|
<string name="event_detail_recurrence">Récurrence</string>
|
||||||
|
<string name="event_detail_recurring">Evénement répété</string>
|
||||||
|
<string name="recurrence_daily">Tous les jours</string>
|
||||||
|
<string name="recurrence_weekly">Toutes les semaines</string>
|
||||||
|
<string name="recurrence_monthly">Tous les mois</string>
|
||||||
|
<string name="recurrence_yearly">Tous les ans</string>
|
||||||
|
<string name="recurrence_every_n_days">Tous les %1$d jours</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Toutes les %1$d semaines</string>
|
||||||
|
<string name="recurrence_every_n_months">Tous les %1$d mois</string>
|
||||||
|
<string name="recurrence_every_n_years">Tous les %1$d ans</string>
|
||||||
|
<string name="recurrence_on_days">%1$s sur %2$s</string>
|
||||||
|
<string name="recurrence_with_until">%1$s jusqu\'à %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d fois</string>
|
||||||
|
<string name="event_detail_not_found">Cet événement n\'existe plus.</string>
|
||||||
|
<string name="event_attendee_accepted">Accepté</string>
|
||||||
|
<string name="event_attendee_declined">Refusé</string>
|
||||||
|
<string name="event_attendee_tentative">Provisoire</string>
|
||||||
|
<string name="event_attendee_needs_action">Aucune réponse</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Rappels</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -16,17 +16,17 @@
|
|||||||
<string name="state_failure_no_calendars">Nessun calendario configurato.</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_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||||
<string name="state_failure_provider">Errore nella lettura del calendario.</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_title">Gestisci magnificamente tutti i tuoi eventi</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_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa di cui ha bisogno e nessuna informazione lascerà mai il tuo dispositivo.</string>
|
||||||
<string name="permission_request_button">Concedi l\'accesso al calendario</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_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_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_open_settings_button">Apri le impostazioni di sistema</string>
|
||||||
<string name="permission_retry_button">Riprova</string>
|
<string name="permission_retry_button">Riprova</string>
|
||||||
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</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_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo dispositivo.</string>
|
||||||
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</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_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_title">Nessun tracking, per sempre</string>
|
||||||
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</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="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<string name="month_action_settings">Impostazioni</string>
|
<string name="month_action_settings">Impostazioni</string>
|
||||||
<string name="month_a11y_today_prefix">Oggi</string>
|
<string name="month_a11y_today_prefix">Oggi</string>
|
||||||
<string name="week_today_action">Questa settimana</string>
|
<string name="week_today_action">Questa settimana</string>
|
||||||
<string name="week_number_label">Wk</string>
|
<string name="week_number_label">Sett.</string>
|
||||||
<string name="day_today_action">Oggi</string>
|
<string name="day_today_action">Oggi</string>
|
||||||
<string name="event_detail_back">Indietro</string>
|
<string name="event_detail_back">Indietro</string>
|
||||||
<string name="event_detail_edit">Modifica</string>
|
<string name="event_detail_edit">Modifica</string>
|
||||||
@@ -73,14 +73,14 @@
|
|||||||
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
||||||
<string name="event_edit_attendees">Partecipanti</string>
|
<string name="event_edit_attendees">Partecipanti</string>
|
||||||
<string name="event_edit_add_guest">Aggiungi partecipante</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_hint">Aggiungi un partecipante tramite indirizzo email…</string>
|
||||||
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</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_location_from_contacts">Scegli un indirizzo dai contatti</string>
|
||||||
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
||||||
<string name="event_edit_attendee_required">Obbligatorio</string>
|
<string name="event_edit_attendee_required">Obbligatorio</string>
|
||||||
<string name="event_edit_attendee_optional">Facoltativo</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_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_attendees_note_local">Salvato sul dispositivo. Nessuno dei partecipanti ha ricevuto notifiche.</string>
|
||||||
<string name="event_edit_reminder_custom">Personalizza</string>
|
<string name="event_edit_reminder_custom">Personalizza</string>
|
||||||
<string name="reminder_unit_minutes">minuti</string>
|
<string name="reminder_unit_minutes">minuti</string>
|
||||||
<string name="reminder_unit_hours">ore</string>
|
<string name="reminder_unit_hours">ore</string>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<string name="event_edit_availability">Disponibilità</string>
|
<string name="event_edit_availability">Disponibilità</string>
|
||||||
<string name="event_edit_visibility">Visibilità</string>
|
<string name="event_edit_visibility">Visibilità</string>
|
||||||
<string name="event_edit_color">Colore</string>
|
<string name="event_edit_color">Colore</string>
|
||||||
<string name="event_edit_color_default">Colore calendario</string>
|
<string name="event_edit_color_default">Colore del calendario</string>
|
||||||
<string name="event_edit_color_custom">Colore personalizzato</string>
|
<string name="event_edit_color_custom">Colore personalizzato</string>
|
||||||
<string name="event_edit_color_reset">Reset</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">Non disponibile per questo calendario</string>
|
||||||
@@ -99,13 +99,13 @@
|
|||||||
<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_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">Salva le modifiche</string>
|
||||||
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</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">Annulla le modifiche</string>
|
||||||
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
|
<string name="event_edit_conflict_discard_hint">Lascia l\'evento invariato</string>
|
||||||
<string name="event_edit_gone_title">Evento eliminato</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_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_none">Non si ripete</string>
|
||||||
<string name="event_edit_recurrence_custom">Personalizza</string>
|
<string name="event_edit_recurrence_custom">Personalizza</string>
|
||||||
<string name="event_edit_recurrence_every">La serie</string>
|
<string name="event_edit_recurrence_every">Ogni</string>
|
||||||
<string name="recurrence_unit_days">giorni</string>
|
<string name="recurrence_unit_days">giorni</string>
|
||||||
<string name="recurrence_unit_weeks">settimane</string>
|
<string name="recurrence_unit_weeks">settimane</string>
|
||||||
<string name="recurrence_unit_months">mesi</string>
|
<string name="recurrence_unit_months">mesi</string>
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
<string name="event_edit_recurrence_ends">Fine</string>
|
<string name="event_edit_recurrence_ends">Fine</string>
|
||||||
<string name="event_edit_recurrence_end_never">Mai</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_until">Fino alla data</string>
|
||||||
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
|
<string name="event_edit_recurrence_end_count">Numero di occorrenze</string>
|
||||||
<string name="event_edit_recurrence_times">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_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
|
||||||
<string name="event_availability_busy">Occupato</string>
|
<string name="event_availability_busy">Occupato</string>
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
<string name="event_detail_location">Luogo</string>
|
<string name="event_detail_location">Luogo</string>
|
||||||
<string name="event_detail_description">Descrizione</string>
|
<string name="event_detail_description">Descrizione</string>
|
||||||
<string name="event_detail_attendees">Partecipanti</string>
|
<string name="event_detail_attendees">Partecipanti</string>
|
||||||
<string name="recurrence_daily">Ogni giorni</string>
|
<string name="recurrence_daily">Ogni giorno</string>
|
||||||
<string name="recurrence_weekly">Ogni settimana</string>
|
<string name="recurrence_weekly">Ogni settimana</string>
|
||||||
<string name="recurrence_monthly">Ogni mese</string>
|
<string name="recurrence_monthly">Ogni mese</string>
|
||||||
<string name="recurrence_yearly">Ogni anno</string>
|
<string name="recurrence_yearly">Ogni anno</string>
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</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_enable_button">Attiva i promemoria</string>
|
||||||
<string name="reminder_onboarding_skip_button">Non ora</string>
|
<string name="reminder_onboarding_skip_button">Non ora</string>
|
||||||
<string name="reminder_action_snooze">Posponi</string>
|
<string name="reminder_action_snooze">Posticipa</string>
|
||||||
<string name="reminder_action_dismiss">Ignora</string>
|
<string name="reminder_action_dismiss">Ignora</string>
|
||||||
<string name="view_month">Mese</string>
|
<string name="view_month">Mese</string>
|
||||||
<string name="view_week">Settimana</string>
|
<string name="view_week">Settimana</string>
|
||||||
@@ -418,16 +418,16 @@
|
|||||||
<string name="settings_default_reminder">Promemoria standard</string>
|
<string name="settings_default_reminder">Promemoria standard</string>
|
||||||
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
||||||
<string name="shortcut_new_event_short">Nuovo 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="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_headings">Carattere del titolo</string>
|
||||||
<string name="settings_font_body">Carattere del corpo</string>
|
<string name="settings_font_body">Carattere del corpo</string>
|
||||||
<string name="settings_font_system">Predefinito di sistema</string>
|
<string name="settings_font_system">Predefinito di sistema</string>
|
||||||
<string name="settings_font_choose_file">Scegli file…</string>
|
<string name="settings_font_choose_file">Scegli file…</string>
|
||||||
<string name="settings_font_custom_selected">Carattere personalizzato</string>
|
<string name="settings_font_custom_selected">Font personalizzato</string>
|
||||||
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
<string name="settings_font_import_failed">Impossibile leggere il file come font</string>
|
||||||
<string name="settings_section_views">Viste</string>
|
<string name="settings_section_views">Viste</string>
|
||||||
<string name="settings_quick_switch_header">Pulsante di cambio rapido</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_quick_switch_hint">Scegli attraverso quali viste scorrere col 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_header">Menù di navigazione</string>
|
||||||
<string name="settings_drawer_order_hint">Trascina per riordinare le viste elencate nel 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="reorder_drag_handle">Trascina per riordinare</string>
|
||||||
@@ -456,4 +456,14 @@
|
|||||||
<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_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="settings_special_dates_disable_confirm">Spegni</string>
|
||||||
<string name="dialog_save">Salva</string>
|
<string name="dialog_save">Salva</string>
|
||||||
|
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||||
|
<string name="settings_calendar_reminders_managed_hint">Impostato nelle date speciali dei contatti</string>
|
||||||
|
<string name="special_dates_calendar_birthday">Compleanni</string>
|
||||||
|
<string name="special_dates_calendar_anniversary">Anniversari</string>
|
||||||
|
<string name="special_dates_calendar_custom">Date speciali</string>
|
||||||
|
<string name="special_dates_default_title_birthday">Compleanno di {name} ({year})</string>
|
||||||
|
<string name="special_dates_default_title_anniversary">Anniversario di {name} ({year})</string>
|
||||||
|
<string name="font_lora">Lora</string>
|
||||||
|
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,4 +6,295 @@
|
|||||||
<item>Days</item>
|
<item>Days</item>
|
||||||
<item>Weeks</item>
|
<item>Weeks</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
<string name="app_tagline">Nowoczesny kalendarz.</string>
|
||||||
|
<string name="state_loading">Ładowanie…</string>
|
||||||
|
<string name="state_retry">Spróbuj ponownie</string>
|
||||||
|
<string name="state_failure_unknown">Coś poszło nie tak.</string>
|
||||||
|
<string name="state_failure_permission">Dostęp do kalendarza jest wymagany.</string>
|
||||||
|
<string name="state_failure_permission_action">Przyznaj dostęp</string>
|
||||||
|
<string name="state_failure_no_calendars">Brak skonfigurowanych kalendarzy.</string>
|
||||||
|
<string name="state_failure_no_calendars_action">Otwórz systemowe ustawienia kalendarza</string>
|
||||||
|
<string name="state_failure_provider">Nie udało się kalendarza.</string>
|
||||||
|
<string name="permission_rationale_title">Zobacz wszystkie swoje wydarzenia w pięknej oprawie</string>
|
||||||
|
<string name="permission_rationale_body">Calendula potrzebuje dostępu do Twojego kalendarza, aby wyświetlać wydarzenia i nimi zarządzać. To jedyne, o co prosi na wstępie — i żadne dane nigdy nie opuszczają Twojego urządzenia.</string>
|
||||||
|
<string name="permission_request_button">Przyznaj dostęp do kalendarza</string>
|
||||||
|
<string name="permission_denied_title">Brak dostępu do kalendarza</string>
|
||||||
|
<string name="permission_denied_body">Calendula nie może wyświetlać wydarzeń bez dostępu do kalendarza. Możesz go ponownie przyznać w ustawieniach systemowych.</string>
|
||||||
|
<string name="permission_open_settings_button">Otwórz ustawienia systemowe</string>
|
||||||
|
<string name="permission_retry_button">Spróbuj ponownie</string>
|
||||||
|
<string name="permission_benefit_private_title">Pozostaje na Twoim urządzeniu</string>
|
||||||
|
<string name="permission_benefit_private_body">Twoje kalendarze są odczytywane lokalnie i nigdy nie opuszczają telefonu.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Wszystkie Twoje kalendarze w jednym miejscu</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, lokalne — wszystko, co jest zsynchronizowane z urządzeniem, po prostu się pojawia.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Nigdy żadnego śledzenia</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Zero telemetrii, zero analityki, bez reklam.</string>
|
||||||
|
<string name="permission_privacy_footnote">Pozostaje na Twoim urządzeniu - brak uprawnień do internetu</string>
|
||||||
|
<string name="month_prev">Poprzedni miesiąc</string>
|
||||||
|
<string name="month_next">Następny miesiąc</string>
|
||||||
|
<string name="month_today_action">Dzisiaj</string>
|
||||||
|
<string name="month_more_actions">Więcej akcji</string>
|
||||||
|
<string name="month_open_menu">Otwórz menu</string>
|
||||||
|
<string name="month_action_settings">Ustawienia</string>
|
||||||
|
<string name="month_a11y_today_prefix">Dzisiaj</string>
|
||||||
|
<string name="week_today_action">Bieżący tydzień</string>
|
||||||
|
<string name="week_number_label">tydz.</string>
|
||||||
|
<string name="day_today_action">Dzisiaj</string>
|
||||||
|
<string name="event_detail_back">Wstecz</string>
|
||||||
|
<string name="event_detail_edit">Edytuj</string>
|
||||||
|
<string name="event_detail_delete">Usuń</string>
|
||||||
|
<string name="event_detail_share">Udostępnij</string>
|
||||||
|
<string name="event_share_chooser_title">Udostępnij wydarzenie</string>
|
||||||
|
<string name="event_share_failed">Nie udało się udostępnić tego wydarzenia.</string>
|
||||||
|
<string name="event_delete_title">Usunąć wydarzenie?</string>
|
||||||
|
<string name="event_delete_body">Usunięto wydarzenie z Twojego kalendarza i wszystkich zsynchronizowanych urządzeń.</string>
|
||||||
|
<string name="event_delete_recurring_title">Usuń powtarzające się wydarzenie</string>
|
||||||
|
<string name="event_delete_option_occurrence">Tylko to wystąpienie</string>
|
||||||
|
<string name="event_delete_option_following">To i wszystkie następne wystąpienia</string>
|
||||||
|
<string name="event_delete_option_series">Wszystkie wystąpienia w serii</string>
|
||||||
|
<string name="event_edit_recurring_title">Edytuj powtarzające się wydarzenie</string>
|
||||||
|
<string name="event_delete_failed">Nie udało się usunąć tego wydarzenia</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula wymaga uprawnień do zapisu, aby móc usuwać wydarzenia</string>
|
||||||
|
<string name="dialog_cancel">Anuluj</string>
|
||||||
|
<string name="dialog_ok">OK</string>
|
||||||
|
<string name="event_edit_new_title">Nowe wydarzenie</string>
|
||||||
|
<string name="event_edit_close">Zamknij</string>
|
||||||
|
<string name="event_edit_save">Zapisz</string>
|
||||||
|
<string name="event_edit_title_hint">Dodaj tytuł</string>
|
||||||
|
<string name="event_edit_managed_hint">Zarządzane przez „%1$s” — tytuł, data i powtarzanie są synchronizowane z Twoimi kontaktami. Możesz edytować przypomnienia, lokalizację oraz notatki.</string>
|
||||||
|
<string name="event_edit_starts">Początek</string>
|
||||||
|
<string name="event_edit_ends">Koniec</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Koniec wcześniejszy niż początek</string>
|
||||||
|
<string name="event_edit_error_no_calendar">Brak dostępnych kalendarzy z uprawnieniami do zapisu</string>
|
||||||
|
<string name="event_edit_save_failed">Nie udało się zapisać wydarzenia</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula potrzebuje uprawnień do zapisu by tworzyć wydarzenia</string>
|
||||||
|
<string name="event_edit_more_fields">Więcej pól</string>
|
||||||
|
<string name="event_edit_add">Dodaj</string>
|
||||||
|
<string name="event_edit_add_reminder">Dodaj przypomnienie</string>
|
||||||
|
<string name="event_edit_remove_reminder">Usuń przypomnienie</string>
|
||||||
|
<string name="event_edit_attendees">Goście</string>
|
||||||
|
<string name="event_edit_add_guest">Dodaj gościa</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Dodaj gościa poprzez adres e-mail…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Dodaj z kontaktów</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Wybierz adres z kontaktów</string>
|
||||||
|
<string name="event_edit_remove_guest">Usuń gościa</string>
|
||||||
|
<string name="event_edit_attendee_required">Wymagany</string>
|
||||||
|
<string name="event_edit_attendee_optional">Opcjonalny</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula nie wysyła zaproszeń. Twoje konto kalendarza może wysyłać e-maile do gości podczas synchronizacji.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Przechowywane na urządzeniu. Nikt nie jest powiadamiany.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Niestandardowy</string>
|
||||||
|
<string name="reminder_unit_minutes">minuty</string>
|
||||||
|
<string name="reminder_unit_hours">godziny</string>
|
||||||
|
<string name="reminder_unit_days">dni</string>
|
||||||
|
<string name="reminder_unit_weeks">tygodnie</string>
|
||||||
|
<string name="event_edit_availability">Dostępność</string>
|
||||||
|
<string name="event_edit_visibility">Widoczność</string>
|
||||||
|
<string name="event_edit_color">Kolor</string>
|
||||||
|
<string name="event_edit_color_default">Kolor kalendarza</string>
|
||||||
|
<string name="event_edit_color_custom">Niestandardowy kolor</string>
|
||||||
|
<string name="event_edit_color_reset">Resetuj</string>
|
||||||
|
<string name="event_edit_color_unsupported">Niedostępne dla tego kalendarza</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Ten kalendarz nie udostępnia zestawu kolorów. Możesz zezwolić na niestandardowe kolory dla takich kalendarzy w Ustawieniach.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Ten kalendarz może usunąć lub nadpisać kolor przy następnej synchronizacji.</string>
|
||||||
|
<string name="event_edit_conflict_title">Wydarzenie zostało zmienione w innym miejscu</string>
|
||||||
|
<string name="event_edit_conflict_body">Podczas edycji to wydarzenie zostało zmienione przez synchronizację lub inną aplikację. Co ma się stać z Twoimi zmianami?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Zapisz moje zmiany</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Tylko edytowane przez Ciebie pola nadpiszą zmiany zewnętrzne</string>
|
||||||
|
<string name="event_edit_conflict_discard">Odrzuć moje zmiany</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">Wydarzenie pozostaje bez zmian</string>
|
||||||
|
<string name="event_edit_gone_title">Wydarzenie usunięte</string>
|
||||||
|
<string name="event_edit_gone_body">To wydarzenie zostało w międzyczasie usunięte, na przykład na innym urządzeniu. Twoje zmiany nie mogą już zostać zapisane.</string>
|
||||||
|
<string name="import_reminder_prompt_title">Użyć domyślnego przypomnienia?</string>
|
||||||
|
<string name="import_reminder_prompt_body_none">To wydarzenie zostało zaimportowane bez żadnych przypomnień.</string>
|
||||||
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
|
<item quantity="one">To wydarzenie zostało zaimportowane z %1$d przypomnieniem.</item>
|
||||||
|
<item quantity="few">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
|
||||||
|
<item quantity="many">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
|
||||||
|
<item quantity="other">To wydarzenie zostało zaimportowane z %1$d przypomnieniami.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_reminder_prompt_apply">Użyj domyślnego</string>
|
||||||
|
<string name="import_reminder_prompt_keep">Pozostaw bez zmian</string>
|
||||||
|
<string name="event_edit_recurrence_none">Nie powtarza się</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Własne</string>
|
||||||
|
<string name="event_edit_recurrence_every">Co</string>
|
||||||
|
<string name="recurrence_unit_days">dni</string>
|
||||||
|
<string name="recurrence_unit_weeks">tygodnie</string>
|
||||||
|
<string name="recurrence_unit_months">miesiące</string>
|
||||||
|
<string name="recurrence_unit_years">lata</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Koniec</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Nigdy</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">Wybrana data</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Po wybranej liczbie powtórzeń</string>
|
||||||
|
<string name="event_edit_recurrence_times">razy</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">Koniec powtarzania przed początkiem wydarzenia</string>
|
||||||
|
<string name="event_availability_busy">Zajęty</string>
|
||||||
|
<string name="event_access_default">Domyślny</string>
|
||||||
|
<string name="event_access_public">Publiczny</string>
|
||||||
|
<string name="event_detail_all_day">Cały dzień</string>
|
||||||
|
<string name="event_detail_calendar">Kalendarz</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Nieznany kalendarz</string>
|
||||||
|
<string name="event_detail_location">Lokalizacja</string>
|
||||||
|
<string name="event_detail_description">Opis</string>
|
||||||
|
<string name="event_detail_attendees">Uczestnicy</string>
|
||||||
|
<string name="event_detail_recurrence">Powtarzanie</string>
|
||||||
|
<string name="event_detail_recurring">Powtarzające się wydarzenie</string>
|
||||||
|
<string name="recurrence_daily">Codziennie</string>
|
||||||
|
<string name="recurrence_weekly">Co tydzień</string>
|
||||||
|
<string name="recurrence_monthly">Co miesiąc</string>
|
||||||
|
<string name="recurrence_yearly">Co rok</string>
|
||||||
|
<string name="recurrence_every_n_days">Co %1$d dni</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Co %1$d tygodni</string>
|
||||||
|
<string name="recurrence_every_n_months">Co %1$d miesięcy</string>
|
||||||
|
<string name="recurrence_every_n_years">Co %1$d lata</string>
|
||||||
|
<string name="recurrence_on_days">%1$s w %2$s</string>
|
||||||
|
<string name="recurrence_with_until">%1$s do %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d razy</string>
|
||||||
|
<string name="event_detail_not_found">To wydarzenie już nie istnieje.</string>
|
||||||
|
<string name="event_attendee_accepted">Zaakceptowano</string>
|
||||||
|
<string name="event_attendee_declined">Odrzucono</string>
|
||||||
|
<string name="event_attendee_tentative">Niepewny</string>
|
||||||
|
<string name="event_attendee_needs_action">Brak odpowiedzi</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Przypomnienia</string>
|
||||||
|
<string name="event_detail_timezone">Strefa czasowa</string>
|
||||||
|
<string name="event_status_tentative">Niepewne</string>
|
||||||
|
<string name="event_status_cancelled">Odwołane</string>
|
||||||
|
<string name="event_availability_free">Dostępny</string>
|
||||||
|
<string name="event_access_private">Prywatne</string>
|
||||||
|
<string name="event_access_confidential">Poufne</string>
|
||||||
|
<string name="event_attendee_organizer">Organizator</string>
|
||||||
|
<string name="event_attendee_optional">Opcjonalny</string>
|
||||||
|
<string name="event_attendee_resource">Zasób</string>
|
||||||
|
<string name="event_detail_self_response">Twoja odpowiedź: %1$s</string>
|
||||||
|
<string name="reminder_at_time">W momencie wydarzenia</string>
|
||||||
|
<string name="reminder_default">Domyślne przypomnienie</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minuta przed</item>
|
||||||
|
<item quantity="few">%d minuty przed</item>
|
||||||
|
<item quantity="many">%d minut przed</item>
|
||||||
|
<item quantity="other">%d minut przed</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d godzina przed</item>
|
||||||
|
<item quantity="few">%d godziny przed</item>
|
||||||
|
<item quantity="many">%d godzin przed</item>
|
||||||
|
<item quantity="other">%d godzin przed</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d dzień przed</item>
|
||||||
|
<item quantity="few">%d dni przed</item>
|
||||||
|
<item quantity="many">%d dni przed</item>
|
||||||
|
<item quantity="other">%d dni przed</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d tydzień przed</item>
|
||||||
|
<item quantity="few">%d tygodnie przed</item>
|
||||||
|
<item quantity="many">%d tygodni przed</item>
|
||||||
|
<item quantity="other">%d tygodni przed</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minuta</item>
|
||||||
|
<item quantity="few">%d minuty</item>
|
||||||
|
<item quantity="many">%d minut</item>
|
||||||
|
<item quantity="other">%d minut</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d godzina</item>
|
||||||
|
<item quantity="few">%d godziny</item>
|
||||||
|
<item quantity="many">%d godzin</item>
|
||||||
|
<item quantity="other">%d godzin</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d dzień</item>
|
||||||
|
<item quantity="few">%d dni</item>
|
||||||
|
<item quantity="many">%d dni</item>
|
||||||
|
<item quantity="other">%d dni</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d tydzień</item>
|
||||||
|
<item quantity="few">%d tygodnie</item>
|
||||||
|
<item quantity="many">%d tygodni</item>
|
||||||
|
<item quantity="other">%d tygodni</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(Bez tytułu)</string>
|
||||||
|
<string name="reminder_channel_name">Przypomnienia o wydarzeniach</string>
|
||||||
|
<string name="reminder_channel_description">Powiadomienia w czasie przypomnień o Twoich wydarzeniach</string>
|
||||||
|
<string name="reminder_onboarding_title">Nigdy nie przegap wydarzenia</string>
|
||||||
|
<string name="reminder_onboarding_body">Android sam nie wyświetla przypomnień o wydarzeniach — musi to robić aplikacja kalendarza. Pozwól na to Calenduli.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Niezawodne przypomnienia</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Każde przypomnienie o Twoich wydarzeniach przychodzi jako powiadomienie, dokładnie na czas.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Używasz innej aplikacji kalendarza?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Jeśli inna aplikacja również wysyła przypomnienia, będą one widoczne podwójnie - wyłącz je tam lub tutaj.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Możesz to zmienić w dowolnej chwili</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Przełącznik znajduje się w Ustawieniach, w sekcji Powiadomienia.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Włącz przypomnienia</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Nie teraz</string>
|
||||||
|
<string name="reminder_action_snooze">Drzemka</string>
|
||||||
|
<string name="reminder_action_dismiss">Odrzuć</string>
|
||||||
|
<string name="view_month">Miesiąc</string>
|
||||||
|
<string name="view_week">Tydzień</string>
|
||||||
|
<string name="view_day">Dzień</string>
|
||||||
|
<string name="view_agenda">Agenda</string>
|
||||||
|
<string name="view_section">Widok</string>
|
||||||
|
<string name="drawer_jump_to_date">Skocz do daty</string>
|
||||||
|
<string name="agenda_today_action">Dzisiaj</string>
|
||||||
|
<string name="agenda_header_today">Dzisiaj</string>
|
||||||
|
<string name="agenda_header_tomorrow">Jutro</string>
|
||||||
|
<string name="agenda_empty_title">Jesteś na bieżąco</string>
|
||||||
|
<string name="search_action">Szukaj</string>
|
||||||
|
<string name="search_hint">Szukaj wydarzeń</string>
|
||||||
|
<string name="search_back">Wstecz</string>
|
||||||
|
<string name="search_clear">Wyczyść</string>
|
||||||
|
<string name="search_idle_hint">Przeszukuj swoje wydarzenia według tytułu, lokalizacji lub notatek.</string>
|
||||||
|
<string name="search_empty">Brak wydarzeń pasujących do „%1$s”.</string>
|
||||||
|
<string name="widget_agenda_title">Nadchodzące wydarzenia</string>
|
||||||
|
<string name="widget_agenda_label">Agenda Calendula</string>
|
||||||
|
<string name="widget_month_label">Miesiąc Calendula</string>
|
||||||
|
<string name="widget_refresh">Odśwież</string>
|
||||||
|
<string name="widget_new_event">Nowe wydarzenie</string>
|
||||||
|
<string name="widget_needs_permission">Otwórz Calendula aby przyznać dostęp do kalendarza</string>
|
||||||
|
<string name="widget_prev_month">Poprzedni miesiąc</string>
|
||||||
|
<string name="widget_next_month">Następny miesiąc</string>
|
||||||
|
<string name="widget_today">Dzisiaj</string>
|
||||||
|
<string name="filter_title">Kalendarze</string>
|
||||||
|
<string name="settings_title">Ustawienia</string>
|
||||||
|
<string name="settings_back">Wstecz</string>
|
||||||
|
<string name="back">Wstecz</string>
|
||||||
|
<string name="settings_section_appearance">Wygląd</string>
|
||||||
|
<string name="settings_theme">Motyw</string>
|
||||||
|
<string name="settings_theme_system">Systemowy</string>
|
||||||
|
<string name="settings_theme_light">Jasny</string>
|
||||||
|
<string name="settings_theme_dark">Ciemny</string>
|
||||||
|
<string name="settings_default_view">Domyślny widok</string>
|
||||||
|
<string name="settings_dynamic_color">Dynamiczne kolory</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Wymaga Androida 12 lub nowszego</string>
|
||||||
|
<string name="settings_font_headings">Czcionka nagłówków</string>
|
||||||
|
<string name="settings_font_body">Czcionka tekstu</string>
|
||||||
|
<string name="settings_font_system">Domyślna systemu</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">Wybierz plik…</string>
|
||||||
|
<string name="settings_font_custom_selected">Niestandardowa czcionka</string>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="settings_font_import_failed">Nie udało się odczytać czcionki z pliku</string>
|
||||||
|
<string name="settings_week_start">Pierwszy dzień tygodnia</string>
|
||||||
|
<string name="settings_week_start_auto">Automatycznie</string>
|
||||||
|
<string name="settings_week_numbers">Numery tygodni</string>
|
||||||
|
<string name="settings_week_numbers_summary">Pokaż numery tygodni w widoku miesiąca</string>
|
||||||
|
<string name="settings_time_format">Format czasu</string>
|
||||||
|
<string name="settings_time_format_auto">Automatyczny</string>
|
||||||
|
<string name="settings_time_format_12h">12-godzinny (2:00 PM)</string>
|
||||||
|
<string name="settings_time_format_24h">24-godzinny (14:00)</string>
|
||||||
|
<string name="settings_hour_lines">Linie godzin</string>
|
||||||
|
<string name="settings_hour_lines_summary">Pokaż linię oddzielającą przy każdej godzinie w widoku tygodnia i dnia</string>
|
||||||
|
<string name="settings_dim_completed">Przyciemnij zakończone wydarzenia</string>
|
||||||
|
<string name="settings_dim_completed_summary">Przyciemnij wydarzenia, które już się zakończyły w widoku miesiąca i tygodnia</string>
|
||||||
|
<string name="settings_past_events">Przeszłe wydarzenia</string>
|
||||||
|
<string name="settings_past_events_show">Pokaż</string>
|
||||||
|
<string name="settings_past_events_dim">Przyciemnij</string>
|
||||||
|
<string name="settings_past_events_hide">Ukryj</string>
|
||||||
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
|
<string name="settings_agenda_range">Zakres agendy</string>
|
||||||
|
<string name="settings_agenda_range_hint">Jak daleko w przód wyświetlane są wydarzenia na ekranie Agendy.</string>
|
||||||
|
<string name="settings_agenda_widget_range">Zakres widżetu agendy</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
<string name="event_detail_edit">Edit</string>
|
<string name="event_detail_edit">Edit</string>
|
||||||
<string name="event_detail_delete">Delete</string>
|
<string name="event_detail_delete">Delete</string>
|
||||||
<string name="event_detail_share">Share</string>
|
<string name="event_detail_share">Share</string>
|
||||||
|
<string name="event_detail_duplicate">Duplicate</string>
|
||||||
<string name="event_share_chooser_title">Share event</string>
|
<string name="event_share_chooser_title">Share event</string>
|
||||||
<string name="event_share_failed">Couldn\'t share this event.</string>
|
<string name="event_share_failed">Couldn\'t share this event.</string>
|
||||||
<string name="event_delete_title">Delete event?</string>
|
<string name="event_delete_title">Delete event?</string>
|
||||||
@@ -117,6 +118,16 @@
|
|||||||
<string name="event_edit_gone_title">Event deleted</string>
|
<string name="event_edit_gone_title">Event deleted</string>
|
||||||
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
<string name="event_edit_gone_body">This event was deleted in the meantime, for example on another device. Your changes can no longer be saved.</string>
|
||||||
|
|
||||||
|
<!-- Event form — apply default reminder to an imported .ics event (#49) -->
|
||||||
|
<string name="import_reminder_prompt_title">Apply your default reminder?</string>
|
||||||
|
<string name="import_reminder_prompt_body_none">This event was imported without any reminder.</string>
|
||||||
|
<plurals name="import_reminder_prompt_body_existing">
|
||||||
|
<item quantity="one">This event was imported with %1$d reminder.</item>
|
||||||
|
<item quantity="other">This event was imported with %1$d reminders.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_reminder_prompt_apply">Apply default</string>
|
||||||
|
<string name="import_reminder_prompt_keep">Keep as-is</string>
|
||||||
|
|
||||||
<!-- Event form — recurrence picker (v1.3) -->
|
<!-- Event form — recurrence picker (v1.3) -->
|
||||||
<string name="event_edit_recurrence_none">Does not repeat</string>
|
<string name="event_edit_recurrence_none">Does not repeat</string>
|
||||||
<string name="event_edit_recurrence_custom">Custom</string>
|
<string name="event_edit_recurrence_custom">Custom</string>
|
||||||
@@ -226,6 +237,9 @@
|
|||||||
<string name="reminder_onboarding_skip_button">Not now</string>
|
<string name="reminder_onboarding_skip_button">Not now</string>
|
||||||
<string name="reminder_action_snooze">Snooze</string>
|
<string name="reminder_action_snooze">Snooze</string>
|
||||||
<string name="reminder_action_dismiss">Dismiss</string>
|
<string name="reminder_action_dismiss">Dismiss</string>
|
||||||
|
<!-- Day context prefix in a reminder for an event on another day (v2.15.0) -->
|
||||||
|
<string name="reminder_day_tomorrow">Tomorrow</string>
|
||||||
|
<string name="reminder_day_yesterday">Yesterday</string>
|
||||||
|
|
||||||
<!-- View switcher (M1) -->
|
<!-- View switcher (M1) -->
|
||||||
<string name="view_month">Month</string>
|
<string name="view_month">Month</string>
|
||||||
@@ -242,6 +256,7 @@
|
|||||||
<string name="agenda_header_today">Today</string>
|
<string name="agenda_header_today">Today</string>
|
||||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||||
<string name="agenda_empty_title">You\'re all caught up</string>
|
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||||
|
<string name="agenda_no_more_today">No more events today</string>
|
||||||
|
|
||||||
<!-- Event search -->
|
<!-- Event search -->
|
||||||
<string name="search_action">Search</string>
|
<string name="search_action">Search</string>
|
||||||
@@ -268,6 +283,8 @@
|
|||||||
<!-- Settings (M4) -->
|
<!-- Settings (M4) -->
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
<string name="settings_back">Back</string>
|
<string name="settings_back">Back</string>
|
||||||
|
<!-- Overrides floret-kit components' CollapsingScaffold back-button label. -->
|
||||||
|
<string name="back">Back</string>
|
||||||
<string name="settings_section_appearance">Appearance</string>
|
<string name="settings_section_appearance">Appearance</string>
|
||||||
<string name="settings_theme">Theme</string>
|
<string name="settings_theme">Theme</string>
|
||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
@@ -287,6 +304,8 @@
|
|||||||
<string name="settings_font_import_failed">Couldn\'t read that file as a 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">Week starts on</string>
|
||||||
<string name="settings_week_start_auto">Automatic</string>
|
<string name="settings_week_start_auto">Automatic</string>
|
||||||
|
<string name="settings_week_numbers">Week numbers</string>
|
||||||
|
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
||||||
<string name="settings_time_format">Time format</string>
|
<string name="settings_time_format">Time format</string>
|
||||||
<string name="settings_time_format_auto">Automatic</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_12h">12-hour (2:00 PM)</string>
|
||||||
@@ -304,6 +323,8 @@
|
|||||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</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">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_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||||
|
<string name="settings_agenda_show_today">Always show today</string>
|
||||||
|
<string name="settings_agenda_show_today_hint">Keep today at the top of the agenda and its widget, even once nothing is left today.</string>
|
||||||
<string name="settings_agenda_range_bar">Range bar</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="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_day">Today</string>
|
||||||
@@ -511,15 +532,15 @@
|
|||||||
|
|
||||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||||
Gitea issue (the app sends nothing automatically). -->
|
Gitea issue (the app sends nothing automatically). -->
|
||||||
<string name="crash_dialog_title">Calendula crashed</string>
|
<string name="crash_dialog_title">%1$s crashed</string>
|
||||||
<string name="crash_dialog_message">Calendula closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
|
<string name="crash_dialog_message">%1$s closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
|
||||||
<string name="crash_dialog_report">Report</string>
|
<string name="crash_dialog_report">Report</string>
|
||||||
<string name="crash_dialog_dismiss">Not now</string>
|
<string name="crash_dialog_dismiss">Not now</string>
|
||||||
<string name="crash_report_issue_title">Crash report</string>
|
<string name="crash_report_issue_title">Crash report</string>
|
||||||
<string name="crash_report_clip_label">Calendula crash report</string>
|
<string name="crash_report_clip_label">%1$s crash report</string>
|
||||||
<string name="crash_report_copied">Report copied to your clipboard</string>
|
<string name="crash_report_copied">Report copied to your clipboard</string>
|
||||||
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
||||||
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
|
<string name="crash_report_body_template">Thanks for reporting a crash in %1$s. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%2$s\n</string>
|
||||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
<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_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>
|
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||||
|
|||||||
@@ -11,5 +11,8 @@
|
|||||||
<locale android:name="en" />
|
<locale android:name="en" />
|
||||||
<locale android:name="de" />
|
<locale android:name="de" />
|
||||||
<locale android:name="es" />
|
<locale android:name="es" />
|
||||||
|
<locale android:name="fr" />
|
||||||
<locale android:name="it" />
|
<locale android:name="it" />
|
||||||
|
<locale android:name="pl" />
|
||||||
|
<locale android:name="zh-CN" />
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|||||||
@@ -312,6 +312,46 @@ class CalendarRepositoryImplTest {
|
|||||||
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
|
assertThat(fake.updatedEvents).containsExactly(Triple(42L, original, updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moveEvent forwards id, target calendar and both forms`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply { nextInsertId = 77L }
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
val original = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "Stand-up",
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)),
|
||||||
|
)
|
||||||
|
val updated = original.copy(calendarId = 3L)
|
||||||
|
|
||||||
|
val newId = repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = original, updated = updated)
|
||||||
|
|
||||||
|
assertThat(newId).isEqualTo(77L)
|
||||||
|
assertThat(fake.movedEvents).containsExactly(
|
||||||
|
FakeCalendarDataSource.MovedEvent(42L, 3L, original, updated),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moveEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
writeError = WriteFailedException("insert moved event into calendar id=3")
|
||||||
|
}
|
||||||
|
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repo.moveEvent(eventId = 42L, targetCalendarId = 3L, original = form, updated = form.copy(calendarId = 3L))
|
||||||
|
error("Expected WriteFailedException")
|
||||||
|
} catch (expected: WriteFailedException) {
|
||||||
|
assertThat(expected.message).contains("3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
fun `updateEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource().apply {
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class EventDetailMapperTest {
|
|||||||
accessLevel: Any? = null,
|
accessLevel: Any? = null,
|
||||||
timezone: String? = null,
|
timezone: String? = null,
|
||||||
selfStatus: Any? = null,
|
selfStatus: Any? = null,
|
||||||
|
duration: String? = null,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
EventDetailProjection.IDX_EVENT_ID to eventId,
|
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||||
EventDetailProjection.IDX_TITLE to title,
|
EventDetailProjection.IDX_TITLE to title,
|
||||||
@@ -51,6 +52,7 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
|
||||||
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
|
||||||
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
EventDetailProjection.IDX_EVENT_COLOR_KEY to eventColorKey,
|
||||||
|
EventDetailProjection.IDX_DURATION to duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun attendeeReader(
|
private fun attendeeReader(
|
||||||
@@ -134,12 +136,32 @@ class EventDetailMapperTest {
|
|||||||
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() {
|
||||||
// A yearly birthday/anniversary anchored before the epoch has a
|
// A yearly birthday/anniversary anchored before the epoch has a
|
||||||
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
// legitimately negative UTC epoch-millis DTSTART; recurring rows carry
|
||||||
// no DTEND (they use DURATION), so it stays end == begin.
|
// no DTEND (they use DURATION), so the length comes from DURATION.
|
||||||
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
val begin = -157_766_400_000L // 1965-01-01T00:00:00Z
|
||||||
val detail = detailReader(dtstart = begin, dtend = null).toDetail()
|
val detail = detailReader(dtstart = begin, dtend = null, duration = "P1D").toDetail()
|
||||||
assertThat(detail).isNotNull()
|
assertThat(detail).isNotNull()
|
||||||
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin + 86_400_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent dtend takes its length from DURATION (issue #48)`() {
|
||||||
|
// A recurring series row has no DTEND. Opened without a named
|
||||||
|
// occurrence — a bare content://…/events/<id> VIEW intent — the row's
|
||||||
|
// own times are what render, so DURATION has to supply the length or
|
||||||
|
// the event shows as zero-length (10:00–10:00).
|
||||||
|
val begin = 1_000_000_000L
|
||||||
|
val detail = detailReader(dtstart = begin, dtend = null, duration = "PT1H").toDetail()
|
||||||
|
assertThat(detail).isNotNull()
|
||||||
|
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin + 3_600_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent dtend and absent DURATION stays zero-length`() {
|
||||||
|
val begin = 1_000_000_000L
|
||||||
|
val detail = detailReader(dtstart = begin, dtend = null, duration = null).toDetail()
|
||||||
|
assertThat(detail).isNotNull()
|
||||||
|
assertThat(detail!!.instance.end.toEpochMilliseconds()).isEqualTo(begin)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -220,6 +220,117 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- buildOccurrenceCancelValues ("delete only this event") ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `occurrence cancel anchors a single instance and cancels only it`() {
|
||||||
|
val values = buildOccurrenceCancelValues(
|
||||||
|
originalInstanceMillis = 1_700_000_000_000L,
|
||||||
|
dtStartMillis = 1_700_000_000_000L,
|
||||||
|
duration = "P3600S",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
||||||
|
.isEqualTo(1_700_000_000_000L)
|
||||||
|
// DTSTART + DURATION make the provider derive a single instance and drop
|
||||||
|
// the inherited RRULE, so only this occurrence is cancelled — not the
|
||||||
|
// whole series (#47). DTEND is never sent (the provider rejects it).
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_700_000_000_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||||
|
assertThat(values[CalendarContract.Events.STATUS])
|
||||||
|
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day occurrence cancel keeps the all-day flag and utc zone`() {
|
||||||
|
val values = buildOccurrenceCancelValues(
|
||||||
|
originalInstanceMillis = 1_700_000_000_000L,
|
||||||
|
dtStartMillis = 1_700_000_000_000L,
|
||||||
|
duration = "P1D",
|
||||||
|
timezone = "UTC",
|
||||||
|
allDay = 1,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||||
|
assertThat(values[CalendarContract.Events.STATUS])
|
||||||
|
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildOccurrenceExdateValues ("delete only this event", no _sync_id) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exdate drop excludes the occurrence and rewrites the recurrence set`() {
|
||||||
|
// 2026-07-15T08:00:00Z.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = null,
|
||||||
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
||||||
|
// The whole time/recurrence set rides along: an EXDATE-only update is not
|
||||||
|
// treated as a recurrence change, so the provider would leave the expanded
|
||||||
|
// instances (and the occurrence) in place. DTSTART alone is worse — it
|
||||||
|
// makes the provider recompute lastDate as a single instance and collapse
|
||||||
|
// the series to its first occurrence.
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_783_929_600_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY;COUNT=5")
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("PT1H")
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exdate drop appends to an existing exdate list`() {
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = "20260714T080000Z",
|
||||||
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE])
|
||||||
|
.isEqualTo("20260714T080000Z,20260715T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exdate drop folds away a repeated occurrence`() {
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = "20260715T080000Z",
|
||||||
|
occurrenceMillis = 1_784_102_400_000L,
|
||||||
|
dtStartMillis = 1_783_929_600_000L,
|
||||||
|
rrule = "FREQ=DAILY;COUNT=5",
|
||||||
|
duration = "PT1H",
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
allDay = 0,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day exdate drop uses the date-only form`() {
|
||||||
|
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
|
||||||
|
val values = buildOccurrenceExdateValues(
|
||||||
|
existingExdate = null,
|
||||||
|
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
|
||||||
|
dtStartMillis = 1_783_900_800_000L,
|
||||||
|
rrule = "FREQ=YEARLY",
|
||||||
|
duration = "P1D",
|
||||||
|
timezone = "UTC",
|
||||||
|
allDay = 1,
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
|
||||||
|
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
// --- per-event colour ---
|
// --- per-event colour ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -298,4 +409,114 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR, null)
|
||||||
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_COLOR_KEY, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- buildMovedMasterValues (calendar move: verbatim series copy) ---
|
||||||
|
|
||||||
|
private fun masterSnapshot(
|
||||||
|
isAllDay: Boolean = false,
|
||||||
|
dtStartMillis: Long = 1_781_164_800_000L,
|
||||||
|
dtEndMillis: Long? = 1_781_164_800_000L + 5_400_000L,
|
||||||
|
duration: String? = null,
|
||||||
|
rrule: String? = null,
|
||||||
|
rdate: String? = null,
|
||||||
|
exdate: String? = null,
|
||||||
|
) = MasterEventSnapshot(
|
||||||
|
title = "Standup",
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
dtStartMillis = dtStartMillis,
|
||||||
|
dtEndMillis = dtEndMillis,
|
||||||
|
duration = duration,
|
||||||
|
rrule = rrule,
|
||||||
|
rdate = rdate,
|
||||||
|
exdate = exdate,
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
availability = CalendarContract.Events.AVAILABILITY_BUSY,
|
||||||
|
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
|
||||||
|
status = CalendarContract.Events.STATUS_CONFIRMED,
|
||||||
|
location = "Room 1",
|
||||||
|
description = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moved one-off carries target calendar, uid and DTEND but no recurrence`() {
|
||||||
|
val values = buildMovedMasterValues(masterSnapshot(), targetCalendarId = 9L, uid = "u@calendula")
|
||||||
|
assertThat(values[CalendarContract.Events.CALENDAR_ID]).isEqualTo(9L)
|
||||||
|
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("u@calendula")
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_164_800_000L + 5_400_000L)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
||||||
|
// Empty description clears explicitly; a raw/keyed colour is never copied
|
||||||
|
// (may be invalid on the target account — the copy inherits its colour).
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moved series preserves the recurrence skeleton as RRULE plus DURATION`() {
|
||||||
|
val values = buildMovedMasterValues(
|
||||||
|
masterSnapshot(dtEndMillis = null, duration = "P5400S", rrule = "FREQ=WEEKLY", exdate = "20260618T080000Z"),
|
||||||
|
targetCalendarId = 9L,
|
||||||
|
uid = "u@calendula",
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||||
|
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260618T080000Z")
|
||||||
|
// Recurring rows never carry DTEND (the provider's invariant).
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moved series without a stored duration derives it from DTEND`() {
|
||||||
|
val values = buildMovedMasterValues(
|
||||||
|
masterSnapshot(duration = null, rrule = "FREQ=DAILY"),
|
||||||
|
targetCalendarId = 9L,
|
||||||
|
uid = "u@calendula",
|
||||||
|
)
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- buildCopiedExceptionValues (calendar move: exception replay) ---
|
||||||
|
|
||||||
|
private fun exceptionSnapshot(
|
||||||
|
isCancelled: Boolean = false,
|
||||||
|
duration: String? = null,
|
||||||
|
// Occurrence starts one hour into the day and runs 30 minutes.
|
||||||
|
dtEndMillis: Long? = 1_781_164_800_000L + 3_600_000L + 1_800_000L,
|
||||||
|
) = ExceptionRowSnapshot(
|
||||||
|
exceptionEventId = 42L,
|
||||||
|
originalInstanceMillis = 1_781_164_800_000L,
|
||||||
|
isCancelled = isCancelled,
|
||||||
|
status = if (isCancelled) CalendarContract.Events.STATUS_CANCELED else CalendarContract.Events.STATUS_CONFIRMED,
|
||||||
|
title = "Moved occurrence",
|
||||||
|
isAllDay = false,
|
||||||
|
dtStartMillis = 1_781_164_800_000L + 3_600_000L,
|
||||||
|
dtEndMillis = dtEndMillis,
|
||||||
|
duration = duration,
|
||||||
|
timezone = "Europe/Berlin",
|
||||||
|
availability = CalendarContract.Events.AVAILABILITY_BUSY,
|
||||||
|
accessLevel = CalendarContract.Events.ACCESS_DEFAULT,
|
||||||
|
location = "",
|
||||||
|
description = "Notes",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `copied modified occurrence carries the original instance and DURATION not DTEND`() {
|
||||||
|
val values = buildCopiedExceptionValues(exceptionSnapshot())
|
||||||
|
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
||||||
|
.isEqualTo(1_781_164_800_000L)
|
||||||
|
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved occurrence")
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L + 3_600_000L)
|
||||||
|
// 30-minute occurrence derived from DTEND, written as DURATION only.
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P1800S")
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||||
|
assertThat(values).containsEntry(CalendarContract.Events.EVENT_LOCATION, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `copied modified occurrence keeps a stored duration verbatim`() {
|
||||||
|
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
|
|
||||||
val insertedForms = mutableListOf<EventForm>()
|
val insertedForms = mutableListOf<EventForm>()
|
||||||
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
val updatedEvents = mutableListOf<Triple<Long, EventForm, EventForm>>()
|
||||||
|
data class MovedEvent(
|
||||||
|
val eventId: Long,
|
||||||
|
val targetCalendarId: Long,
|
||||||
|
val original: EventForm,
|
||||||
|
val updated: EventForm,
|
||||||
|
)
|
||||||
|
val movedEvents = mutableListOf<MovedEvent>()
|
||||||
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
val updatedOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||||
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
val updatedFromOccurrences = mutableListOf<Triple<Long, Long, EventForm>>()
|
||||||
val deletedEventIds = mutableListOf<Long>()
|
val deletedEventIds = mutableListOf<Long>()
|
||||||
@@ -116,6 +123,19 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
allDayReminderTimes += allDayReminderTimeMinutes
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun moveEvent(
|
||||||
|
eventId: Long,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
allDayReminderTimeMinutes: Int,
|
||||||
|
): Long {
|
||||||
|
writeError?.let { throw it }
|
||||||
|
movedEvents += MovedEvent(eventId, targetCalendarId, original, updated)
|
||||||
|
allDayReminderTimes += allDayReminderTimeMinutes
|
||||||
|
return nextInsertId
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
beginMillis: Long,
|
beginMillis: Long,
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import kotlin.time.Instant
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
class TimeBridgeTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `epoch millis round-trips through Instant`() {
|
|
||||||
val original = 1_717_840_800_000L // 2024-06-08T10:00:00Z
|
|
||||||
val instant = original.toKotlinInstantFromEpochMillis()
|
|
||||||
assertThat(instant.toEpochMillis()).isEqualTo(original)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `zero millis maps to Instant epoch`() {
|
|
||||||
assertThat(0L.toKotlinInstantFromEpochMillis()).isEqualTo(Instant.fromEpochMilliseconds(0L))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `negative epoch millis is supported`() {
|
|
||||||
val original = -1_000_000L
|
|
||||||
assertThat(original.toKotlinInstantFromEpochMillis().toEpochMillis()).isEqualTo(original)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
@@ -169,7 +169,7 @@ class SpecialDatesSyncEngineTest {
|
|||||||
engine.sync()
|
engine.sync()
|
||||||
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
||||||
|
|
||||||
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440)))
|
engine.applyReminders(SpecialDateType.Birthday, ReminderOverride.Minutes(listOf(0, 1440)))
|
||||||
|
|
||||||
// Persisted as the per-calendar all-day override (so new events match)...
|
// Persisted as the per-calendar all-day override (so new events match)...
|
||||||
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal])
|
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal])
|
||||||
@@ -187,7 +187,7 @@ class SpecialDatesSyncEngineTest {
|
|||||||
engine.sync()
|
engine.sync()
|
||||||
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
||||||
|
|
||||||
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None)
|
engine.applyReminders(SpecialDateType.Birthday, ReminderOverride.None)
|
||||||
|
|
||||||
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty()
|
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty()
|
||||||
assertThat(calendars.appliedManagedReminders.last().third).isEmpty()
|
assertThat(calendars.appliedManagedReminders.last().third).isEmpty()
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.crash
|
package de.jeanlucmakiola.calendula.data.crash
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashContext
|
||||||
|
import de.jeanlucmakiola.floret.crash.buildCrashReport
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards the privacy contract of the report Calendula ships: the report builder
|
||||||
|
* itself now lives in floret-kit's core-crash, but the allowlist — exactly the
|
||||||
|
* app/Android/device/locale/time header lines plus the stack trace, and nothing
|
||||||
|
* else — is what Calendula relies on, so it stays pinned here with Calendula's
|
||||||
|
* own app label.
|
||||||
|
*/
|
||||||
class CrashReportBuilderTest {
|
class CrashReportBuilderTest {
|
||||||
|
|
||||||
private val context = CrashContext(
|
private val context = CrashContext(
|
||||||
@@ -17,7 +26,7 @@ class CrashReportBuilderTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `report carries the allowlisted facts and the stack trace`() {
|
fun `report carries the allowlisted facts and the stack trace`() {
|
||||||
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L)
|
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L, appLabel = "Calendula")
|
||||||
|
|
||||||
assertThat(report).startsWith("Calendula crash report")
|
assertThat(report).startsWith("Calendula crash report")
|
||||||
assertThat(report).contains("App version: 2.7.0 (20700)")
|
assertThat(report).contains("App version: 2.7.0 (20700)")
|
||||||
@@ -33,7 +42,7 @@ class CrashReportBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `nested causes are included`() {
|
fun `nested causes are included`() {
|
||||||
val cause = NullPointerException("inner")
|
val cause = NullPointerException("inner")
|
||||||
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L)
|
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L, appLabel = "Calendula")
|
||||||
|
|
||||||
assertThat(report).contains("outer")
|
assertThat(report).contains("outer")
|
||||||
assertThat(report).contains("Caused by")
|
assertThat(report).contains("Caused by")
|
||||||
@@ -42,7 +51,7 @@ class CrashReportBuilderTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `report holds only the allowlisted lines before the stack trace`() {
|
fun `report holds only the allowlisted lines before the stack trace`() {
|
||||||
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L)
|
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L, appLabel = "Calendula")
|
||||||
val header = report.substringBefore("Stack trace:").trim().lines()
|
val header = report.substringBefore("Stack trace:").trim().lines()
|
||||||
|
|
||||||
// No identifiers, accounts, or extra fields ever creep into the header:
|
// No identifiers, accounts, or extra fields ever creep into the header:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.prefs
|
package de.jeanlucmakiola.calendula.data.prefs
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
@@ -95,6 +97,14 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.showHourLines.first()).isTrue()
|
assertThat(prefs.showHourLines.first()).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.showWeekNumbers.first()).isFalse()
|
||||||
|
prefs.setShowWeekNumbers(true)
|
||||||
|
assertThat(prefs.showWeekNumbers.first()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
|
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
@@ -323,14 +333,14 @@ class SettingsPrefsTest {
|
|||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
||||||
|
|
||||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
|
prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(listOf(15, 10_080)))
|
||||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
|
prefs.setCalendarReminderOverride(9L, ReminderOverride.None)
|
||||||
prefs.perCalendarReminderOverride.first().let { map ->
|
prefs.perCalendarReminderOverride.first().let { map ->
|
||||||
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
|
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inherit drops the override entirely (absent != an empty-list value).
|
// Inherit drops the override entirely (absent != an empty-list value).
|
||||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
|
prefs.setCalendarReminderOverride(9L, ReminderOverride.Inherit)
|
||||||
prefs.perCalendarReminderOverride.first().let { map ->
|
prefs.perCalendarReminderOverride.first().let { map ->
|
||||||
assertThat(map).containsExactly(7L, listOf(15, 10_080))
|
assertThat(map).containsExactly(7L, listOf(15, 10_080))
|
||||||
assertThat(map.containsKey(9L)).isFalse()
|
assertThat(map.containsKey(9L)).isFalse()
|
||||||
@@ -370,12 +380,12 @@ class SettingsPrefsTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
|
prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(listOf(15)))
|
||||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
|
prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Minutes(listOf(1_440)))
|
||||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
|
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
|
||||||
// Clearing the all-day override leaves the timed one untouched.
|
// Clearing the all-day override leaves the timed one untouched.
|
||||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
|
prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Inherit)
|
||||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
||||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.reminders
|
|||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.DayOfWeek
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -18,41 +19,110 @@ class ReminderTimeTextTest {
|
|||||||
private fun utcMidnight(date: LocalDate): Long =
|
private fun utcMidnight(date: LocalDate): Long =
|
||||||
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
|
||||||
|
|
||||||
|
/** Wrapper defaulting `today` to the event's own begin day, so unrelated tests read "today". */
|
||||||
|
private fun text(
|
||||||
|
beginMillis: Long,
|
||||||
|
endMillis: Long,
|
||||||
|
isAllDay: Boolean = false,
|
||||||
|
zone: ZoneId = berlin,
|
||||||
|
locale: Locale = Locale.GERMANY,
|
||||||
|
is24Hour: Boolean = true,
|
||||||
|
today: LocalDate? = null,
|
||||||
|
firstDayOfWeek: DayOfWeek = DayOfWeek.MONDAY,
|
||||||
|
): String = reminderTimeText(
|
||||||
|
beginMillis = beginMillis,
|
||||||
|
endMillis = endMillis,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
zone = zone,
|
||||||
|
locale = locale,
|
||||||
|
is24Hour = is24Hour,
|
||||||
|
today = today ?: java.time.Instant.ofEpochMilli(beginMillis).atZone(zone).toLocalDate(),
|
||||||
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
|
tomorrowLabel = "Tomorrow",
|
||||||
|
yesterdayLabel = "Yesterday",
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `timed event on one day shows just the time range`() {
|
fun `timed event today shows just the time range`() {
|
||||||
val text = reminderTimeText(
|
val text = text(
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 9, 30), berlin),
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 10, 0), berlin),
|
||||||
isAllDay = false,
|
|
||||||
zone = berlin,
|
|
||||||
locale = Locale.GERMANY,
|
|
||||||
is24Hour = true,
|
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("09:30 – 10:00")
|
assertThat(text).isEqualTo("09:30 – 10:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `12-hour preference renders an am-pm time range`() {
|
fun `12-hour preference renders an am-pm time range`() {
|
||||||
val text = reminderTimeText(
|
val text = text(
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
|
||||||
isAllDay = false,
|
|
||||||
zone = berlin,
|
|
||||||
locale = Locale.US,
|
locale = Locale.US,
|
||||||
is24Hour = false,
|
is24Hour = false,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("2:00 PM – 3:00 PM")
|
assertThat(text).isEqualTo("2:00 PM – 3:00 PM")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tomorrow's event is prefixed with the tomorrow label`() {
|
||||||
|
val text = text(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 12, 9, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 10, 0), berlin),
|
||||||
|
today = LocalDate.of(2026, 6, 11),
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("Tomorrow, 09:30 – 10:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `yesterday's event is prefixed with the yesterday label`() {
|
||||||
|
val text = text(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 10, 9, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 10, 10, 0), berlin),
|
||||||
|
today = LocalDate.of(2026, 6, 11),
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("Yesterday, 09:30 – 10:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event later this week is prefixed with the short weekday`() {
|
||||||
|
// 2026-06-11 is a Thursday; +3 days lands on Sunday, still this (Mon-based) week.
|
||||||
|
val text = text(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
|
||||||
|
today = LocalDate.of(2026, 6, 11),
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("So., 09:30 – 10:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the week boundary honours the week-start setting`() {
|
||||||
|
// Today Thu 2026-06-11, event Sun 2026-06-14. With a Sunday-start week the
|
||||||
|
// Thursday's week runs Sun 06-07..Sat 06-13, so 06-14 is already next week
|
||||||
|
// and must render as a date — the opposite of the Monday-start case above.
|
||||||
|
val text = text(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 14, 9, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 14, 10, 0), berlin),
|
||||||
|
today = LocalDate.of(2026, 6, 11),
|
||||||
|
firstDayOfWeek = DayOfWeek.SUNDAY,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("14.06.2026, 09:30 – 10:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event next week falls back to the exact date, not a weekday`() {
|
||||||
|
// 2026-06-15 is the following Monday — a weekday alone would be ambiguous.
|
||||||
|
val text = text(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 15, 9, 30), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 15, 10, 0), berlin),
|
||||||
|
today = LocalDate.of(2026, 6, 11),
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("15.06.2026, 09:30 – 10:00")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `timed event crossing midnight includes both dates`() {
|
fun `timed event crossing midnight includes both dates`() {
|
||||||
val text = reminderTimeText(
|
val text = text(
|
||||||
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 23, 30), berlin),
|
||||||
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 12, 0, 30), berlin),
|
||||||
isAllDay = false,
|
|
||||||
zone = berlin,
|
|
||||||
locale = Locale.GERMANY,
|
|
||||||
is24Hour = true,
|
|
||||||
)
|
)
|
||||||
assertThat(text).contains("11.06.2026")
|
assertThat(text).contains("11.06.2026")
|
||||||
assertThat(text).contains("12.06.2026")
|
assertThat(text).contains("12.06.2026")
|
||||||
@@ -61,28 +131,35 @@ class ReminderTimeTextTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day single day shows one date, read in UTC`() {
|
fun `all-day single day shows one date, read in UTC`() {
|
||||||
val text = reminderTimeText(
|
val text = text(
|
||||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
// Zone must not matter for all-day events: UTC midnight is
|
// Zone must not matter for all-day events: UTC midnight is
|
||||||
// 02:00 in Berlin — naive local reading would shift the day.
|
// 02:00 in Berlin — naive local reading would shift the day.
|
||||||
zone = berlin,
|
today = LocalDate.of(2026, 6, 11),
|
||||||
locale = Locale.GERMANY,
|
|
||||||
is24Hour = true,
|
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026")
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day event tomorrow still shows the exact date, no relative prefix`() {
|
||||||
|
val text = text(
|
||||||
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 12)),
|
||||||
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||||
|
isAllDay = true,
|
||||||
|
today = LocalDate.of(2026, 6, 11),
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("12.06.2026")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
|
fun `all-day multi-day shows the last covered day, not the exclusive end`() {
|
||||||
val text = reminderTimeText(
|
val text = text(
|
||||||
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
beginMillis = utcMidnight(LocalDate.of(2026, 6, 11)),
|
||||||
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
endMillis = utcMidnight(LocalDate.of(2026, 6, 13)),
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
zone = berlin,
|
today = LocalDate.of(2026, 6, 11),
|
||||||
locale = Locale.GERMANY,
|
|
||||||
is24Hour = true,
|
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
||||||
}
|
}
|
||||||
@@ -90,13 +167,11 @@ class ReminderTimeTextTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `degenerate all-day range never renders an inverted span`() {
|
fun `degenerate all-day range never renders an inverted span`() {
|
||||||
val day = utcMidnight(LocalDate.of(2026, 6, 11))
|
val day = utcMidnight(LocalDate.of(2026, 6, 11))
|
||||||
val text = reminderTimeText(
|
val text = text(
|
||||||
beginMillis = day,
|
beginMillis = day,
|
||||||
endMillis = day,
|
endMillis = day,
|
||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
zone = berlin,
|
today = LocalDate.of(2026, 6, 11),
|
||||||
locale = Locale.GERMANY,
|
|
||||||
is24Hour = true,
|
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026")
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class EventColorPaletteTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty palette stays empty`() {
|
||||||
|
assertThat(emptyList<EventColorOption>().curatedForPicker()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exact duplicate values collapse to the alphabetically first key`() {
|
||||||
|
val curated = listOf(
|
||||||
|
EventColorOption("cyan", 0xFF00FFFF.toInt()),
|
||||||
|
EventColorOption("aqua", 0xFF00FFFF.toInt()),
|
||||||
|
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||||
|
).curatedForPicker()
|
||||||
|
|
||||||
|
assertThat(curated.map { it.key }).containsExactly("aqua", "red")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `small palettes pass through whole, so Google's curated set is untouched`() {
|
||||||
|
// A Google-like palette: two dozen distinct hand-picked colours.
|
||||||
|
val palette = (0 until 24).map {
|
||||||
|
val hue = it * 15
|
||||||
|
EventColorOption("$it", hsvArgb(hue.toFloat()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val curated = palette.curatedForPicker()
|
||||||
|
|
||||||
|
assertThat(curated).containsExactlyElementsIn(palette)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `oversized CSS3 palette thins to a pickable number of distinct swatches`() {
|
||||||
|
val curated = css3Palette().curatedForPicker()
|
||||||
|
|
||||||
|
// The whole point of #22: ~147 published colours become a single
|
||||||
|
// manageable grid instead of a full screen.
|
||||||
|
assertThat(curated.size).isAtLeast(30)
|
||||||
|
assertThat(curated.size).isAtMost(60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `curation never invents colours or drops keys`() {
|
||||||
|
val source = css3Palette()
|
||||||
|
val curated = source.curatedForPicker()
|
||||||
|
|
||||||
|
assertThat(source).containsAtLeastElementsIn(curated)
|
||||||
|
assertThat(curated.map { it.argb }).containsNoDuplicates()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `spelling-alias pairs never both survive`() {
|
||||||
|
val keys = css3Palette().curatedForPicker().map { it.key }.toSet()
|
||||||
|
|
||||||
|
val aliasPairs = listOf(
|
||||||
|
"aqua" to "cyan",
|
||||||
|
"fuchsia" to "magenta",
|
||||||
|
"gray" to "grey",
|
||||||
|
"darkgray" to "darkgrey",
|
||||||
|
"dimgray" to "dimgrey",
|
||||||
|
"lightgray" to "lightgrey",
|
||||||
|
"slategray" to "slategrey",
|
||||||
|
"lightslategray" to "lightslategrey",
|
||||||
|
"darkslategray" to "darkslategrey",
|
||||||
|
)
|
||||||
|
aliasPairs.forEach { (a, b) ->
|
||||||
|
assertThat(keys.contains(a) && keys.contains(b)).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
|
||||||
|
// Black and every gray paint as the same pale swatch (the picker pins
|
||||||
|
// lightness and floors saturation), so only one survives — no stranded
|
||||||
|
// run of look-alike "pinks" at the end of the grid (#22).
|
||||||
|
val curated = listOf(
|
||||||
|
EventColorOption("black", 0xFF000000.toInt()),
|
||||||
|
EventColorOption("gray", 0xFF808080.toInt()),
|
||||||
|
EventColorOption("darkgray", 0xFFA9A9A9.toInt()),
|
||||||
|
EventColorOption("blue", 0xFF0000FF.toInt()),
|
||||||
|
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||||
|
).curatedForPicker().map { it.key }
|
||||||
|
|
||||||
|
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
|
||||||
|
assertThat(curated).containsAtLeast("black", "red", "blue")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a dark and a light shade of one hue collapse to a single swatch`() {
|
||||||
|
// The picker paints every swatch at one fixed lightness, so navy and a
|
||||||
|
// mid blue are indistinguishable once painted — keep just one.
|
||||||
|
val curated = listOf(
|
||||||
|
EventColorOption("navy", 0xFF000080.toInt()),
|
||||||
|
EventColorOption("blue", 0xFF0000FF.toInt()),
|
||||||
|
).curatedForPicker()
|
||||||
|
|
||||||
|
assertThat(curated).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the wheel is cut once, keeping each hue family contiguous`() {
|
||||||
|
// Twelve pure hues, deliberately shuffled; a small palette passes the
|
||||||
|
// thinning stage untouched so only the ordering is under test.
|
||||||
|
val shuffledHues = listOf(0, 300, 60, 180, 120, 240, 30, 330, 90, 210, 150, 270)
|
||||||
|
val curated = shuffledHues
|
||||||
|
.map { EventColorOption("$it", hsvArgb(it.toFloat())) }
|
||||||
|
.curatedForPicker()
|
||||||
|
.map { it.key.toInt() }
|
||||||
|
|
||||||
|
// A proper single-seam sweep around the wheel descends exactly once
|
||||||
|
// (at the seam). The old bucketed sort could scatter a family across
|
||||||
|
// both ends, producing extra descents.
|
||||||
|
val descents = curated.indices.count { i ->
|
||||||
|
curated[(i + 1) % curated.size] < curated[i]
|
||||||
|
}
|
||||||
|
assertThat(descents).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `CSS3 survivors span the whole rainbow`() {
|
||||||
|
val keys = css3Palette().curatedForPicker().map { it.key }
|
||||||
|
fun has(vararg families: String) = keys.any { k -> families.any { k.contains(it) } }
|
||||||
|
|
||||||
|
// Which exact name represents a hue family depends on the vivid-first
|
||||||
|
// thinning, so assert each family survives, not a specific key.
|
||||||
|
assertThat(has("red", "crimson", "firebrick", "tomato", "maroon", "brown")).isTrue()
|
||||||
|
assertThat(has("orange", "gold", "goldenrod", "peru", "sienna", "salmon")).isTrue()
|
||||||
|
assertThat(has("green", "olive", "lime", "chartreuse", "forest", "sea")).isTrue()
|
||||||
|
assertThat(has("blue", "navy", "dodger", "steel", "royal", "sky", "aqua")).isTrue()
|
||||||
|
assertThat(has("violet", "purple", "magenta", "orchid", "fuchsia", "indigo", "plum")).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hsvArgb(hue: Float): Int {
|
||||||
|
val h = hue / 60f
|
||||||
|
val sector = h.toInt() % 6
|
||||||
|
val f = h - h.toInt()
|
||||||
|
val q = ((1 - f) * 255).toInt()
|
||||||
|
val t = (f * 255).toInt()
|
||||||
|
return when (sector) {
|
||||||
|
0 -> argb(255, t, 0)
|
||||||
|
1 -> argb(q, 255, 0)
|
||||||
|
2 -> argb(0, 255, t)
|
||||||
|
3 -> argb(0, q, 255)
|
||||||
|
4 -> argb(t, 0, 255)
|
||||||
|
else -> argb(255, 0, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun argb(r: Int, g: Int, b: Int): Int =
|
||||||
|
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
|
||||||
|
/** The exact set ical4android/DAVx5 publishes: CSS3's 147 named colours. */
|
||||||
|
private fun css3Palette(): List<EventColorOption> = CSS3.map { (name, rgb) ->
|
||||||
|
EventColorOption(name, 0xFF000000.toInt() or rgb)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val CSS3 = mapOf(
|
||||||
|
"aliceblue" to 0xF0F8FF, "antiquewhite" to 0xFAEBD7, "aqua" to 0x00FFFF,
|
||||||
|
"aquamarine" to 0x7FFFD4, "azure" to 0xF0FFFF, "beige" to 0xF5F5DC,
|
||||||
|
"bisque" to 0xFFE4C4, "black" to 0x000000, "blanchedalmond" to 0xFFEBCD,
|
||||||
|
"blue" to 0x0000FF, "blueviolet" to 0x8A2BE2, "brown" to 0xA52A2A,
|
||||||
|
"burlywood" to 0xDEB887, "cadetblue" to 0x5F9EA0, "chartreuse" to 0x7FFF00,
|
||||||
|
"chocolate" to 0xD2691E, "coral" to 0xFF7F50, "cornflowerblue" to 0x6495ED,
|
||||||
|
"cornsilk" to 0xFFF8DC, "crimson" to 0xDC143C, "cyan" to 0x00FFFF,
|
||||||
|
"darkblue" to 0x00008B, "darkcyan" to 0x008B8B, "darkgoldenrod" to 0xB8860B,
|
||||||
|
"darkgray" to 0xA9A9A9, "darkgreen" to 0x006400, "darkgrey" to 0xA9A9A9,
|
||||||
|
"darkkhaki" to 0xBDB76B, "darkmagenta" to 0x8B008B, "darkolivegreen" to 0x556B2F,
|
||||||
|
"darkorange" to 0xFF8C00, "darkorchid" to 0x9932CC, "darkred" to 0x8B0000,
|
||||||
|
"darksalmon" to 0xE9967A, "darkseagreen" to 0x8FBC8F, "darkslateblue" to 0x483D8B,
|
||||||
|
"darkslategray" to 0x2F4F4F, "darkslategrey" to 0x2F4F4F, "darkturquoise" to 0x00CED1,
|
||||||
|
"darkviolet" to 0x9400D3, "deeppink" to 0xFF1493, "deepskyblue" to 0x00BFFF,
|
||||||
|
"dimgray" to 0x696969, "dimgrey" to 0x696969, "dodgerblue" to 0x1E90FF,
|
||||||
|
"firebrick" to 0xB22222, "floralwhite" to 0xFFFAF0, "forestgreen" to 0x228B22,
|
||||||
|
"fuchsia" to 0xFF00FF, "gainsboro" to 0xDCDCDC, "ghostwhite" to 0xF8F8FF,
|
||||||
|
"gold" to 0xFFD700, "goldenrod" to 0xDAA520, "gray" to 0x808080,
|
||||||
|
"green" to 0x008000, "greenyellow" to 0xADFF2F, "grey" to 0x808080,
|
||||||
|
"honeydew" to 0xF0FFF0, "hotpink" to 0xFF69B4, "indianred" to 0xCD5C5C,
|
||||||
|
"indigo" to 0x4B0082, "ivory" to 0xFFFFF0, "khaki" to 0xF0E68C,
|
||||||
|
"lavender" to 0xE6E6FA, "lavenderblush" to 0xFFF0F5, "lawngreen" to 0x7CFC00,
|
||||||
|
"lemonchiffon" to 0xFFFACD, "lightblue" to 0xADD8E6, "lightcoral" to 0xF08080,
|
||||||
|
"lightcyan" to 0xE0FFFF, "lightgoldenrodyellow" to 0xFAFAD2, "lightgray" to 0xD3D3D3,
|
||||||
|
"lightgreen" to 0x90EE90, "lightgrey" to 0xD3D3D3, "lightpink" to 0xFFB6C1,
|
||||||
|
"lightsalmon" to 0xFFA07A, "lightseagreen" to 0x20B2AA, "lightskyblue" to 0x87CEFA,
|
||||||
|
"lightslategray" to 0x778899, "lightslategrey" to 0x778899, "lightsteelblue" to 0xB0C4DE,
|
||||||
|
"lightyellow" to 0xFFFFE0, "lime" to 0x00FF00, "limegreen" to 0x32CD32,
|
||||||
|
"linen" to 0xFAF0E6, "magenta" to 0xFF00FF, "maroon" to 0x800000,
|
||||||
|
"mediumaquamarine" to 0x66CDAA, "mediumblue" to 0x0000CD, "mediumorchid" to 0xBA55D3,
|
||||||
|
"mediumpurple" to 0x9370DB, "mediumseagreen" to 0x3CB371, "mediumslateblue" to 0x7B68EE,
|
||||||
|
"mediumspringgreen" to 0x00FA9A, "mediumturquoise" to 0x48D1CC,
|
||||||
|
"mediumvioletred" to 0xC71585, "midnightblue" to 0x191970, "mintcream" to 0xF5FFFA,
|
||||||
|
"mistyrose" to 0xFFE4E1, "moccasin" to 0xFFE4B5, "navajowhite" to 0xFFDEAD,
|
||||||
|
"navy" to 0x000080, "oldlace" to 0xFDF5E6, "olive" to 0x808000,
|
||||||
|
"olivedrab" to 0x6B8E23, "orange" to 0xFFA500, "orangered" to 0xFF4500,
|
||||||
|
"orchid" to 0xDA70D6, "palegoldenrod" to 0xEEE8AA, "palegreen" to 0x98FB98,
|
||||||
|
"paleturquoise" to 0xAFEEEE, "palevioletred" to 0xDB7093, "papayawhip" to 0xFFEFD5,
|
||||||
|
"peachpuff" to 0xFFDAB9, "peru" to 0xCD853F, "pink" to 0xFFC0CB,
|
||||||
|
"plum" to 0xDDA0DD, "powderblue" to 0xB0E0E6, "purple" to 0x800080,
|
||||||
|
"red" to 0xFF0000, "rosybrown" to 0xBC8F8F, "royalblue" to 0x4169E1,
|
||||||
|
"saddlebrown" to 0x8B4513, "salmon" to 0xFA8072, "sandybrown" to 0xF4A460,
|
||||||
|
"seagreen" to 0x2E8B57, "seashell" to 0xFFF5EE, "sienna" to 0xA0522D,
|
||||||
|
"silver" to 0xC0C0C0, "skyblue" to 0x87CEEB, "slateblue" to 0x6A5ACD,
|
||||||
|
"slategray" to 0x708090, "slategrey" to 0x708090, "snow" to 0xFFFAFA,
|
||||||
|
"springgreen" to 0x00FF7F, "steelblue" to 0x4682B4, "tan" to 0xD2B48C,
|
||||||
|
"teal" to 0x008080, "thistle" to 0xD8BFD8, "tomato" to 0xFF6347,
|
||||||
|
"turquoise" to 0x40E0D0, "violet" to 0xEE82EE, "wheat" to 0xF5DEB3,
|
||||||
|
"white" to 0xFFFFFF, "whitesmoke" to 0xF5F5F5, "yellow" to 0xFFFF00,
|
||||||
|
"yellowgreen" to 0x9ACD32,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/** Covers the agenda widget's "always show today" anchor logic (#35). */
|
||||||
|
class AnchorTodayTest {
|
||||||
|
|
||||||
|
private val today = LocalDate(2026, 6, 17)
|
||||||
|
private val tomorrow = LocalDate(2026, 6, 18)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabled leaves the days untouched even when today is absent`() {
|
||||||
|
val days = listOf(AgendaDay(tomorrow, emptyList()))
|
||||||
|
assertThat(anchorTodayIfMissing(days, today, enabled = false)).isEqualTo(days)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled prepends an empty today when it is missing`() {
|
||||||
|
val days = listOf(AgendaDay(tomorrow, emptyList()))
|
||||||
|
val result = anchorTodayIfMissing(days, today, enabled = true)
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
assertThat(result.first()).isEqualTo(AgendaDay(today, emptyList()))
|
||||||
|
assertThat(result[1]).isEqualTo(days.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled anchors today into an otherwise empty list`() {
|
||||||
|
val result = anchorTodayIfMissing(emptyList(), today, enabled = true)
|
||||||
|
assertThat(result).containsExactly(AgendaDay(today, emptyList()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled is a no-op when today already has its own day`() {
|
||||||
|
val days = listOf(AgendaDay(today, emptyList()), AgendaDay(tomorrow, emptyList()))
|
||||||
|
assertThat(anchorTodayIfMissing(days, today, enabled = true)).isEqualTo(days)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The pure emit rule behind [ReminderDefaultPicker]: a non-empty selection is
|
|
||||||
* [CalendarReminderOverride.Minutes] (normalised), while clearing the last time
|
|
||||||
* reverts to [CalendarReminderOverride.Inherit] 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.
|
|
||||||
*/
|
|
||||||
class ReminderOverrideForMinutesTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty selection on a per-calendar picker reverts to inherit`() {
|
|
||||||
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = true))
|
|
||||||
.isEqualTo(CalendarReminderOverride.Inherit)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `empty selection on the global default is explicit none`() {
|
|
||||||
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false))
|
|
||||||
.isEqualTo(CalendarReminderOverride.None)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `a non-empty selection is normalised minutes regardless of scope`() {
|
|
||||||
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = true))
|
|
||||||
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
|
|
||||||
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = false))
|
|
||||||
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.edit
|
||||||
|
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focuses on the save-time branch that decides between an in-place update and a
|
||||||
|
* calendar *move* (copy+delete). The provider-level move itself is verified
|
||||||
|
* on-device; here the fake records which repository call the ViewModel chose.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class EventEditViewModelTest {
|
||||||
|
|
||||||
|
private val dispatcher = UnconfinedTestDispatcher()
|
||||||
|
|
||||||
|
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
||||||
|
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
||||||
|
|
||||||
|
private val beginMillis = 1_781_164_800_000L
|
||||||
|
private val endMillis = beginMillis + 3_600_000L
|
||||||
|
|
||||||
|
private fun cal(id: Long): CalendarSource = CalendarSource(
|
||||||
|
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||||
|
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
||||||
|
instance = EventInstance(
|
||||||
|
instanceId = 42L, eventId = 42L, calendarId = calendarId, title = "Standup",
|
||||||
|
start = Instant.fromEpochMilliseconds(beginMillis),
|
||||||
|
end = Instant.fromEpochMilliseconds(endMillis),
|
||||||
|
isAllDay = false, color = 0xFF000000.toInt(), location = null,
|
||||||
|
),
|
||||||
|
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pin the DataStore's scope to the test dispatcher so a settings write (e.g.
|
||||||
|
// last-used calendar) completes under advanceUntilIdle instead of on a real
|
||||||
|
// IO thread the virtual clock can't observe.
|
||||||
|
private fun prefs(tempDir: Path): CalendarPrefs = CalendarPrefs(
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
scope = CoroutineScope(dispatcher),
|
||||||
|
produceFile = { tempDir.resolve("vm_prefs.preferences_pb").toFile() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun settings(tempDir: Path): SettingsPrefs = SettingsPrefs(
|
||||||
|
PreferenceDataStoreFactory.create(
|
||||||
|
scope = CoroutineScope(dispatcher),
|
||||||
|
produceFile = { tempDir.resolve("vm_settings.preferences_pb").toFile() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun viewModel(
|
||||||
|
tempDir: Path,
|
||||||
|
fake: FakeCalendarDataSource,
|
||||||
|
): EventEditViewModel {
|
||||||
|
val p = prefs(tempDir)
|
||||||
|
val s = settings(tempDir)
|
||||||
|
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
||||||
|
return EventEditViewModel(repo, p, s, dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep [EventEditViewModel.state] hot so it computes while the test drives it. */
|
||||||
|
private fun CoroutineScope.activate(vm: EventEditViewModel): Job = launch { vm.state.collect {} }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `changing the calendar routes the save through a move, not an update`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L), cal(2L))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L) }
|
||||||
|
nextInsertId = 99L
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||||
|
vm.setCalendar(2L)
|
||||||
|
vm.save()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(fake.movedEvents).hasSize(1)
|
||||||
|
assertThat(fake.movedEvents.single().eventId).isEqualTo(42L)
|
||||||
|
assertThat(fake.movedEvents.single().targetCalendarId).isEqualTo(2L)
|
||||||
|
assertThat(fake.updatedEvents).isEmpty()
|
||||||
|
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moving a recurring event skips the scope dialog`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L), cal(2L))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||||
|
vm.setCalendar(2L)
|
||||||
|
vm.save()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// No AwaitingScope park: a move is inherently whole-series.
|
||||||
|
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
|
||||||
|
assertThat(fake.movedEvents).hasSize(1)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `editing a recurring event without moving still asks for the scope`(
|
||||||
|
@TempDir tempDir: Path,
|
||||||
|
) = runTest(dispatcher) {
|
||||||
|
val fake = FakeCalendarDataSource().apply {
|
||||||
|
calendarsResult = listOf(cal(1L), cal(2L))
|
||||||
|
eventDetailResult = { detail(calendarId = 1L, rrule = "FREQ=WEEKLY") }
|
||||||
|
}
|
||||||
|
val vm = viewModel(tempDir, fake)
|
||||||
|
val job = activate(vm)
|
||||||
|
|
||||||
|
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||||
|
vm.setTitle("Renamed")
|
||||||
|
vm.save()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.AwaitingScope)
|
||||||
|
assertThat(fake.movedEvents).isEmpty()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,11 +43,21 @@ Builds:
|
|||||||
versionCode: 20705
|
versionCode: 20705
|
||||||
commit: v2.7.5
|
commit: v2.7.5
|
||||||
subdir: app
|
subdir: app
|
||||||
|
submodules: true
|
||||||
gradle:
|
gradle:
|
||||||
- yes
|
- yes
|
||||||
# No NDK / no flavors. The release buildType applies a signingConfig only
|
# No NDK / no flavors. The release buildType applies a signingConfig only
|
||||||
# when key.properties exists; on the buildserver it does not, so this
|
# when key.properties exists; on the buildserver it does not, so this
|
||||||
# produces the unsigned APK F-Droid compares against our binary.
|
# produces the unsigned APK F-Droid compares against our binary.
|
||||||
|
#
|
||||||
|
# submodules: true — REQUIRED from v2.11.0 onward, where the build pulls
|
||||||
|
# shared code from the `floret-kit` git submodule via a Gradle composite
|
||||||
|
# build (`includeBuild("floret-kit")`). Without it F-Droid checks out an
|
||||||
|
# empty floret-kit/ and the from-source build fails, stalling publishing.
|
||||||
|
# Inert for v2.7.5 (no submodule yet); kept here so AutoUpdateMode copies it
|
||||||
|
# onto every auto-generated future build entry. The kit is plain-Kotlin and
|
||||||
|
# carries no foojay toolchain resolver, so it clears the same reproducibility
|
||||||
|
# bar (enforced by scripts/check_reproducible_release.sh).
|
||||||
|
|
||||||
# SHA-256 of our app signing certificate (public; embedded in every published
|
# SHA-256 of our app signing certificate (public; embedded in every published
|
||||||
# APK). Locks F-Droid to publish only binaries signed with our key.
|
# APK). Locks F-Droid to publish only binaries signed with our key.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 198 KiB |