Compare commits
132 Commits
b6bcd195b0
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 7602f158a3 | |||
| 052335e842 | |||
| 28fb7404d8 | |||
| 6ab235e1d8 | |||
| fb90c94e37 | |||
| 7a05fc3ac1 | |||
| bf38dac5d7 | |||
| 1e9581528e | |||
| 279872aa3b | |||
| ff6c4b6a4d | |||
| 7fb01ab8d1 | |||
| bdb16d069e | |||
| beb8536e8a | |||
| 63ba69729e | |||
| 01a6c8bab8 | |||
| f70b33b864 | |||
| 0df420e551 | |||
| 488de490f9 | |||
| f15fffa799 | |||
| 1499b5803d | |||
| 39cc70be95 | |||
| f9619e74d9 | |||
| e691336b5e | |||
| 4cee160c12 | |||
| a7ae8ff912 | |||
| d23a1d5b6d | |||
| b417900ddd | |||
| 95f4d28bbb | |||
| 24b005126e | |||
| ee9d65068d | |||
| 6e3d10858f | |||
| 94a82c2f6c | |||
| 81dcbbdce7 | |||
| becb9a6710 | |||
| 542744e342 | |||
| 7510e1f9af | |||
| 498250650c | |||
| ada6976e85 | |||
| 6a454e0b33 | |||
| cd6ae31d0f | |||
| 463811056d | |||
| 98d76deee5 | |||
| 20e18768eb | |||
| a34f29bcf8 | |||
| 997296e21e | |||
| 94355bf340 | |||
| 722fc87259 | |||
| 30fcbfa59f | |||
| c956ac79ed | |||
| 7d357bad87 | |||
| 3ffe76412e | |||
| 91c4a84818 | |||
| ccd433fee1 | |||
| 03f6b7c8c1 | |||
| 426ddf27ee | |||
| 4148196a36 | |||
| 9a1d753abc | |||
| 1b29abed17 | |||
| 85d72ad051 | |||
| fd3bcdf2f7 | |||
| 46afa830b3 | |||
| 7de9b2f81b | |||
| 3ec46c8631 | |||
| 529dc9c374 | |||
| 60ef920b82 | |||
| 7b7b859fee | |||
| b5c2930609 | |||
| 3feafe38f2 | |||
| 19baed9291 | |||
| 4ea68adf8b | |||
| c3ba8ccf64 | |||
| 7ed1d89b66 | |||
| 734bcae02d | |||
| 629896b74d | |||
| 01ac61185b | |||
| ca01f6e729 | |||
| 0588609c75 | |||
| ab3df639b9 | |||
| fefe1f3652 | |||
| 7634df3cff | |||
| 60938af9f0 | |||
| accf0ac142 | |||
| f90badfcd5 | |||
| 249606a358 | |||
| 5be5ff77d0 | |||
| 6519cbca79 | |||
| 50510a23da | |||
| 2279738371 | |||
| 3d59962112 | |||
| a96d8b6de9 | |||
| 94b9eeaa78 | |||
|
|
85fb091e22 | ||
| 2139f8729c | |||
| 98a48aa795 | |||
| d255d232b9 | |||
| 9999fcdd2a | |||
| 953ffdff97 | |||
| 5377a2b466 | |||
| e73148dc6c | |||
| a19772e3a7 | |||
| b3386eff43 | |||
| 79e74d9995 | |||
| 396a5610aa | |||
| b9329f6fb6 | |||
| 1b731a4ab0 | |||
| ab631365b2 | |||
| 9aa370d583 | |||
|
|
8cd75716ae | ||
|
|
4feccf2008 | ||
| 23e9a5a35b | |||
| 38a35be0f0 | |||
| f9f0572ec5 | |||
| 0221972e6d | |||
| 86ea72d56f | |||
| 114db7939c | |||
| 4503847c0d | |||
| 95c5fe76e6 | |||
| 34fa9c9c3e | |||
| 53793bfb68 | |||
| 9c7c8cb03a | |||
| 4fea176e28 | |||
| cf9492c7ba | |||
| e6def9e5f7 | |||
| 974185f354 | |||
| 6aacdd9111 | |||
| 4f263d00fe | |||
| bda002684c | |||
| 8ddc11159b | |||
|
|
6736bae5ee | ||
| 60aa889c57 | |||
| 3c9767387b | |||
|
|
21d1726d7a |
@@ -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 }}
|
||||||
|
|||||||
191
CHANGELOG.md
191
CHANGELOG.md
@@ -7,7 +7,182 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Choose how the month view is laid out. A new **Month view style** setting
|
||||||
|
(Settings → Views) offers three ways to read a month, each shown with a
|
||||||
|
preview of the layout it produces:
|
||||||
|
- **Pages** — what you have today: one month at a time, swiped sideways.
|
||||||
|
- **Continuous** — scroll up and down through the weeks without a break
|
||||||
|
between months. Because the weeks run on unbroken, no month is cut off and
|
||||||
|
no day appears twice, where paging repeats a boundary week at the end of one
|
||||||
|
month and the start of the next. The 1st of each month names itself so you
|
||||||
|
always know where you are, and the title bar keeps up as you scroll ([#38]).
|
||||||
|
- **Split** — a compact grid showing coloured dots for the days that have
|
||||||
|
something on them, with the day you tap listed in full underneath. Tap the
|
||||||
|
date above the list to open the whole day ([#53]).
|
||||||
|
|
||||||
|
The Agenda view is untouched by this and stays available in all three styles —
|
||||||
|
the split layout lists a single day, while Agenda remains a rolling multi-day
|
||||||
|
window with its own range settings.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- The "Upcoming" agenda widget now scales its text and rows to the size you give
|
||||||
|
it. Previously it was laid out once for the smallest size and simply stretched
|
||||||
|
when enlarged, so the text stayed small no matter how big you made the widget.
|
||||||
|
Now a bigger widget gets bigger, more readable type and roomier rows, while the
|
||||||
|
default size looks exactly as before — no new setting; it follows the size you
|
||||||
|
already chose ([#51]).
|
||||||
|
|
||||||
|
## [2.16.0] — 2026-07-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Give an event its own time zone. A new **Time zone** field (under "more
|
||||||
|
fields" in the event form) pins an event to a specific zone, so a call set for
|
||||||
|
8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps
|
||||||
|
tracking that zone across daylight-saving changes instead of drifting an hour.
|
||||||
|
The form edits the event in its own zone and shows the local equivalent under
|
||||||
|
the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local
|
||||||
|
time first and note the original beneath it, so both are always clear. Pick a
|
||||||
|
zone from a full-screen picker with your device zone and recent choices on top,
|
||||||
|
searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation
|
||||||
|
("CEST") to gather every matching zone at once. All-day events stay
|
||||||
|
date-anchored and carry no zone, as before ([#31]).
|
||||||
|
- Put the "jump to today" button in the toolbar. A new **Today button in
|
||||||
|
toolbar** setting (Settings → Appearance, off by default) swaps the floating
|
||||||
|
button that fades into the corner while you're away from today for a permanent
|
||||||
|
today icon in the top bar — always there, on today or not, matching the
|
||||||
|
familiar calendar-app pattern. Leave it off to keep the floating button as
|
||||||
|
before ([#60]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dates in the Month, Week and Day title bars now follow your language and
|
||||||
|
region instead of one hardcoded layout. Every date was rendered in a fixed
|
||||||
|
German-style order with a trailing dot on the day number, whatever your
|
||||||
|
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||||
|
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||||
|
disagreed about the same date. All four views now share one formatter, and the
|
||||||
|
day/month order, the separators and the ordinal all come from your locale —
|
||||||
|
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||||
|
each correct for where you are ([#60]).
|
||||||
|
- The title bar drops the year while you're in the current one — "July" rather
|
||||||
|
than "July 2026". The year reappears the moment you page out of the current
|
||||||
|
year, which is when it tells you something you didn't already know.
|
||||||
|
- The Week view's title now names the month instead of spelling out the day range.
|
||||||
|
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||||
|
headers right below it, in the widest string in the bar. A week that straddles
|
||||||
|
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- An all-day event no longer shows up again the day after it happened. In time
|
||||||
|
zones east of UTC, an all-day event — a birthday, say — set for one day also
|
||||||
|
appeared under the *next* day's heading in the Agenda (and the agenda widget),
|
||||||
|
because all-day events are anchored to UTC midnight and the following day's
|
||||||
|
window reached back across that boundary and pulled the event forward onto
|
||||||
|
"today". Each all-day event now lists only on the day it actually falls on
|
||||||
|
([#65]).
|
||||||
|
|
||||||
|
## [2.15.0] — 2026-07-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Show raw calendar colours. Calendula normally softens each calendar and event
|
||||||
|
colour toward a theme-fitting pastel so harsh sync colours read well on both
|
||||||
|
light and dark; a new **Soften calendar colours** setting (Settings → Design,
|
||||||
|
on by default) lets you turn that off and paint the exact colours your calendar
|
||||||
|
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
|
||||||
|
@leonp5 for the report ([#36]).
|
||||||
|
- Readable titles on dark event colours. An event bar's title now shows in white
|
||||||
|
on a dark colour and near-black on a light one, chosen automatically from the
|
||||||
|
colour's brightness, so a deep blue or purple event is legible at a glance in
|
||||||
|
the busy Week and Month views instead of dark-on-dark. This applies whether or
|
||||||
|
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
|
||||||
|
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
||||||
|
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
||||||
|
amount and switch between minutes and hours, so a snoozed reminder comes back
|
||||||
|
after exactly the delay you want instead of only a preset one ([#40]).
|
||||||
|
- Move an event to another calendar. When editing an existing event, the
|
||||||
|
calendar row is now tappable — pick a different calendar and saving moves the
|
||||||
|
event across, instead of having to delete it and recreate it elsewhere.
|
||||||
|
Recurring series move as a whole, keeping their individually-edited and
|
||||||
|
cancelled occurrences, and any reminders and guests come along too. A calendar
|
||||||
|
can't simply be reassigned underneath an event, so Calendula recreates it on
|
||||||
|
the target and removes the original — the same approach other calendar apps
|
||||||
|
take. Thanks to @prismplex for the suggestion ([#39]).
|
||||||
|
- Open an event straight into the edit form from another app. Calendula already
|
||||||
|
answered the "new event" and "open this event" hand-offs from other apps and
|
||||||
|
widgets; it now also answers the "edit this event" one, so an assistant, task
|
||||||
|
app, or widget can send an existing event to Calendula and land on its edit
|
||||||
|
screen rather than the read-only details. A hand-off with no event attached
|
||||||
|
opens the same prefilled create form as "new event". Calendula also recognises
|
||||||
|
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||||
|
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||||
|
into Calendula works too.
|
||||||
|
- Keep today at the top of the agenda. A new **Always show today** setting
|
||||||
|
(Settings → Agenda, on by default) anchors today as the first entry in both the
|
||||||
|
Agenda screen and its home-screen widget even once nothing is left today —
|
||||||
|
under today's header a "No more events today" note appears — so the first
|
||||||
|
events you see are clearly today's rather than a future day's. Turn it off to
|
||||||
|
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
|
||||||
|
- Duplicate an event. The event details now carry a **Duplicate** action that
|
||||||
|
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
|
||||||
|
a one-off like a shift or an appointment can be recreated by just changing the
|
||||||
|
day and time instead of re-typing every field. The copy keeps the original's
|
||||||
|
time, and its title, location, notes, colour, guests and reminders come along;
|
||||||
|
it's saved as its own single event (any repeat is left off — add one in the
|
||||||
|
editor if you want it). Duplicate works from read-only calendars too, dropping
|
||||||
|
the copy into a writable one. Thanks to @internet-rando for the suggestion
|
||||||
|
([#52]).
|
||||||
|
- French and Polish, in early form. Calendula has started speaking French and
|
||||||
|
Polish, both contributed as community translations through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
They are partway there, so untranslated parts still show in English until they
|
||||||
|
fill out — you can already pick either under Settings → Language or in Android's
|
||||||
|
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
|
||||||
|
(Polish) for getting them started; help finishing them is very welcome.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Reminders for events on another day no longer read as if they were today. A
|
||||||
|
reminder fired ahead of time — say, the day before — used to show only the
|
||||||
|
event's time, making it look like it was happening now. The notification now
|
||||||
|
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
||||||
|
week, or the date for anything further out. Thanks to @moonj for the report
|
||||||
|
([#46]).
|
||||||
|
- Agenda dates now read in your locale's format. The agenda's range bar and its
|
||||||
|
day headers used a fixed day-month-year layout — and the range span even mixed
|
||||||
|
two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's
|
||||||
|
conventions. Dates across the agenda and its widget now use your locale's own
|
||||||
|
field order, matching the rest of the app. The range bar also no longer repeats
|
||||||
|
the range's name from the selector button beside it, showing just the dates.
|
||||||
|
- Calendar gutters line up with the menu button. The Month view's week-number
|
||||||
|
column, and the Week and Day views' hour labels, sat a few pixels left of the
|
||||||
|
hamburger menu above them; they now line up with it. In Month view the day
|
||||||
|
cells also sit squarely under their weekday letters.
|
||||||
|
|
||||||
|
## [2.14.1] — 2026-07-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Deleting one occurrence of a repeating event no longer breaks the series.
|
||||||
|
Choosing "This event" when deleting an occurrence of a recurring event could
|
||||||
|
wipe out every *other* occurrence while leaving the one you deleted behind as
|
||||||
|
a stale, still-tappable ghost — and deleting it again brought the series back.
|
||||||
|
A single-occurrence delete now removes exactly that occurrence and leaves the
|
||||||
|
rest of the series untouched, and the deleted occurrence disappears from the
|
||||||
|
grid straight away. This holds on every kind of calendar, including the
|
||||||
|
on-device ones Calendula keeps for contact birthdays and anniversaries, where
|
||||||
|
the series is a yearly repeat. Thanks to @moonj for the report ([#47]).
|
||||||
|
- Tapping an event in a third-party widget opens it in Calendula. v2.13.1 taught
|
||||||
|
Calendula to answer the "new event" hand-off from other apps and widgets; now
|
||||||
|
it also answers the "open this event" one, so tapping an existing event in a
|
||||||
|
widget such as Todo Agenda offers Calendula and lands on that event's details.
|
||||||
|
Thanks to @bushrang3r for the report ([#48]).
|
||||||
|
- Events created from other apps get your default reminder. An event handed over
|
||||||
|
by another app or widget — Google Maps' "add to calendar", the Todo Agenda
|
||||||
|
widget's "+" — opened with no reminder at all, ignoring the default set in
|
||||||
|
Settings. It now starts with your default reminder, the same as an event you
|
||||||
|
create in Calendula. An event opened from an `.ics` file is treated differently,
|
||||||
|
because the file has its own say: Calendula keeps whatever reminders it carries
|
||||||
|
(including none at all) and asks you once whether to apply your default instead
|
||||||
|
— it never quietly overrides the file. If you have no default set, it doesn't
|
||||||
|
ask ([#49]).
|
||||||
- A tidy colour picker on CalDAV calendars. For calendars synced by a CalDAV
|
- 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
|
app (such as DAVx5), the event colour picker showed every colour the account
|
||||||
publishes — nearly 150 swatches in alphabetical order, many of them
|
publishes — nearly 150 swatches in alphabetical order, many of them
|
||||||
@@ -883,8 +1058,24 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
|
[#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
|
||||||
|
[#21]: https://codeberg.org/jlmakiola/calendula/issues/21
|
||||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||||
|
[#31]: https://codeberg.org/jlmakiola/calendula/issues/31
|
||||||
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||||
|
[#36]: https://codeberg.org/jlmakiola/calendula/issues/36
|
||||||
[#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
|
||||||
|
[#51]: https://codeberg.org/jlmakiola/calendula/issues/51
|
||||||
|
[#52]: https://codeberg.org/jlmakiola/calendula/issues/52
|
||||||
|
[#38]: https://codeberg.org/jlmakiola/calendula/issues/38
|
||||||
|
[#53]: https://codeberg.org/jlmakiola/calendula/issues/53
|
||||||
|
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
|
||||||
|
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21400
|
versionCode = 21600
|
||||||
versionName = "2.14.0"
|
versionName = "2.16.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
|||||||
import androidx.test.rule.GrantPermissionRule
|
import androidx.test.rule.GrantPermissionRule
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest {
|
|||||||
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||||
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") },
|
||||||
)
|
)
|
||||||
return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO)
|
return CalendarRepositoryImpl(
|
||||||
|
dataSource,
|
||||||
|
CalendarPrefs(store),
|
||||||
|
SettingsPrefs(store),
|
||||||
|
Dispatchers.IO,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -63,10 +63,9 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<!-- The MAIN/LAUNCHER entry (the launcher icon + its label) lives on
|
||||||
<action android:name="android.intent.action.MAIN" />
|
the two <activity-alias> below, so the app name can be switched at
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
runtime (issue #44). MainActivity keeps every other filter. -->
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Be selectable as the system calendar app. Android has no API for
|
<!-- Be selectable as the system calendar app. Android has no API for
|
||||||
an app to make itself the default, so registering the filters
|
an app to make itself the default, so registering the filters
|
||||||
@@ -97,32 +96,58 @@
|
|||||||
<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
|
<!-- Open an existing event another app/widget points at (e.g. tapping
|
||||||
an event in the Todo Agenda widget): ACTION_VIEW on
|
an event in the Todo Agenda widget): ACTION_VIEW on
|
||||||
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
||||||
@@ -139,11 +164,47 @@
|
|||||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
</activity>
|
||||||
|
|
||||||
|
<!-- Launcher entry for MainActivity, split into two aliases so the app's
|
||||||
|
launcher name can be switched at runtime between "Calendula" and
|
||||||
|
"Calendar" (issue #44). Exactly one is enabled at a time; the app
|
||||||
|
flips them via PackageManager.setComponentEnabledSetting
|
||||||
|
(LauncherNameManager). The shortcuts meta-data lives here, not on
|
||||||
|
MainActivity, because static shortcuts are published by whichever
|
||||||
|
component owns the MAIN/LAUNCHER filter. -->
|
||||||
|
<activity-alias
|
||||||
|
android:name=".DefaultNameAlias"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.shortcuts"
|
android:name="android.app.shortcuts"
|
||||||
android:resource="@xml/shortcuts" />
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity-alias>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".CalendarNameAlias"
|
||||||
|
android:enabled="false"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name_calendar_alias"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:targetActivity=".MainActivity">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
<!-- Standalone surface for a captured crash report. MainActivity routes
|
<!-- Standalone surface for a captured crash report. MainActivity routes
|
||||||
here on a startup crash-loop, so it stays clear of the app's Hilt
|
here on a startup crash-loop, so it stays clear of the app's Hilt
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
@@ -70,6 +71,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.
|
||||||
@@ -95,6 +101,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
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
|
||||||
@@ -134,6 +141,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -145,6 +153,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
|
||||||
@@ -183,6 +193,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,13 +214,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),
|
||||||
@@ -318,6 +335,31 @@ class MainActivity : AppCompatActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
|
||||||
|
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
|
||||||
|
* an assistant, task app, or widget that wants to open the event for editing
|
||||||
|
* rather than viewing). Opens it in the edit form. Reuses the same
|
||||||
|
* occurrence-key channel as reminder/view taps; the caller passes the
|
||||||
|
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
|
||||||
|
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
|
||||||
|
* [EventEditViewModel.openForEdit] falls back to the event row's own
|
||||||
|
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
|
||||||
|
*/
|
||||||
|
private fun Intent.editEventKeyOrNull(): LongArray? {
|
||||||
|
if (action != Intent.ACTION_EDIT) return null
|
||||||
|
val uri = data ?: return null
|
||||||
|
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||||
|
val segments = uri.pathSegments
|
||||||
|
if (segments.firstOrNull() != "events") return null
|
||||||
|
val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||||
|
return longArrayOf(
|
||||||
|
eventId,
|
||||||
|
longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME,
|
||||||
|
longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// The calendar provider's authority/host. A date tap arrives as
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.appname
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** The launcher label the app shows for itself (issue #44). */
|
||||||
|
enum class LauncherName { CALENDULA, CALENDAR }
|
||||||
|
|
||||||
|
/** The two launcher aliases declared in the manifest. */
|
||||||
|
enum class LauncherAlias { DEFAULT, CALENDAR }
|
||||||
|
|
||||||
|
/** One component-enable change in a [aliasWritePlan]. */
|
||||||
|
data class AliasStateChange(val alias: LauncherAlias, val enabled: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpret the `CalendarNameAlias` component-enabled state as a [LauncherName].
|
||||||
|
* Only [PackageManager.COMPONENT_ENABLED_STATE_ENABLED] means the "Calendar"
|
||||||
|
* name is active; `DEFAULT` (never toggled) and `DISABLED` both resolve to the
|
||||||
|
* manifest default, "Calendula".
|
||||||
|
*/
|
||||||
|
fun launcherNameFor(calendarAliasState: Int): LauncherName =
|
||||||
|
if (calendarAliasState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||||
|
LauncherName.CALENDAR
|
||||||
|
} else {
|
||||||
|
LauncherName.CALENDULA
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ordered enable/disable steps to switch the launcher name to [target].
|
||||||
|
* Always **enables the target alias first, then disables the other** — the two
|
||||||
|
* `setComponentEnabledSetting` calls are not atomic, and this ordering means the
|
||||||
|
* only transient the launcher can observe is a harmless two-entry state, never a
|
||||||
|
* zero-entry one (which would briefly drop the app from the launcher).
|
||||||
|
*/
|
||||||
|
fun aliasWritePlan(target: LauncherName): List<AliasStateChange> = when (target) {
|
||||||
|
LauncherName.CALENDAR -> listOf(
|
||||||
|
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||||
|
)
|
||||||
|
LauncherName.CALENDULA -> listOf(
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches the app's launcher label between "Calendula" and "Calendar" by
|
||||||
|
* enabling/disabling the two `<activity-alias>` components (issue #44). The
|
||||||
|
* component-enabled state is the single source of truth — there is no persisted
|
||||||
|
* preference — so [current] reads it straight from [PackageManager] and the two
|
||||||
|
* always agree.
|
||||||
|
*
|
||||||
|
* The decision logic lives in the pure [launcherNameFor] / [aliasWritePlan]
|
||||||
|
* functions above (JVM-tested); this class is only the thin framework seam.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LauncherNameManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
private val packageManager: PackageManager get() = context.packageManager
|
||||||
|
|
||||||
|
// The class portion is namespace-qualified (suffix-free), the package portion
|
||||||
|
// is the applicationId (which carries the .debug / .releasetest suffix). The
|
||||||
|
// manifest's ".CalendarNameAlias" resolves its class against the namespace, so
|
||||||
|
// the real component is <namespace>.CalendarNameAlias registered under the
|
||||||
|
// suffixed applicationId. Do NOT use ComponentName(context, ".CalendarNameAlias")
|
||||||
|
// — its leading-dot form prepends the applicationId to the class too, producing
|
||||||
|
// "<appId>.CalendarNameAlias" and failing on every debug / releaseTest build.
|
||||||
|
private fun component(alias: LauncherAlias): ComponentName {
|
||||||
|
val simpleName = when (alias) {
|
||||||
|
LauncherAlias.DEFAULT -> "DefaultNameAlias"
|
||||||
|
LauncherAlias.CALENDAR -> "CalendarNameAlias"
|
||||||
|
}
|
||||||
|
return ComponentName(context.packageName, "$NAMESPACE.$simpleName")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The launcher name currently in effect. */
|
||||||
|
fun current(): LauncherName =
|
||||||
|
launcherNameFor(packageManager.getComponentEnabledSetting(component(LauncherAlias.CALENDAR)))
|
||||||
|
|
||||||
|
/** Switch the launcher name to [name] (no-op cost if already active). */
|
||||||
|
fun set(name: LauncherName) {
|
||||||
|
for (change in aliasWritePlan(name)) {
|
||||||
|
val state = if (change.enabled) {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
} else {
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||||
|
}
|
||||||
|
packageManager.setComponentEnabledSetting(
|
||||||
|
component(change.alias),
|
||||||
|
state,
|
||||||
|
PackageManager.DONT_KILL_APP,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* The app's namespace (R-class package) — NOT `applicationId`, which
|
||||||
|
* carries the build-type suffix. Kept in sync with `namespace` in
|
||||||
|
* `app/build.gradle.kts`.
|
||||||
|
*/
|
||||||
|
const val NAMESPACE = "de.jeanlucmakiola.calendula"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import android.util.Log
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
@@ -179,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
|
||||||
@@ -885,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,
|
||||||
@@ -979,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 ->
|
||||||
@@ -989,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
|
||||||
@@ -1001,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(
|
||||||
@@ -1191,15 +1403,38 @@ 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
|
|
||||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It has to
|
|
||||||
// carry the full time set (DTSTART + DURATION + zone), not just STATUS:
|
|
||||||
// the provider only demotes the cloned exception to a single instance —
|
|
||||||
// clearing the inherited RRULE — when it can derive that instance from
|
|
||||||
// those columns. A STATUS-only cancel left the RRULE standing and
|
|
||||||
// cancelled the whole series, wiping every other occurrence (#47), the
|
|
||||||
// same trap the edit path documents (Codeberg #16).
|
|
||||||
val row = querySeriesRow(eventId)
|
val row = querySeriesRow(eventId)
|
||||||
|
if (row.syncId == null) {
|
||||||
|
// No _sync_id — a local calendar, or a synced event not pushed yet.
|
||||||
|
// A cancelled exception can only attach to its parent through
|
||||||
|
// 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(
|
val values = buildOccurrenceCancelValues(
|
||||||
originalInstanceMillis = beginMillis,
|
originalInstanceMillis = beginMillis,
|
||||||
dtStartMillis = beginMillis,
|
dtStartMillis = 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`.
|
||||||
|
|||||||
@@ -157,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,
|
||||||
|
|||||||
@@ -14,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
|
||||||
@@ -36,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import de.jeanlucmakiola.calendula.domain.Availability
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
|
import java.time.LocalDateTime as JavaLocalDateTime
|
||||||
|
|
||||||
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */
|
||||||
internal data class EventWriteTimes(
|
internal data class EventWriteTimes(
|
||||||
@@ -20,7 +22,11 @@ internal data class EventWriteTimes(
|
|||||||
/**
|
/**
|
||||||
* All-day events live at UTC midnights with an exclusive DTEND (the
|
* All-day events live at UTC midnights with an exclusive DTEND (the
|
||||||
* CalendarContract convention — a one-day event ends at the next midnight);
|
* CalendarContract convention — a one-day event ends at the next midnight);
|
||||||
* timed events resolve their wall-clock values in [zone].
|
* timed events resolve their wall-clock values in the form's own
|
||||||
|
* [EventForm.timezone], falling back to [zone] (the device) when it doesn't pin
|
||||||
|
* one. Passing the device zone is therefore still correct for an unpinned form —
|
||||||
|
* but it no longer overrides a pinned event's zone, which is what used to
|
||||||
|
* silently re-anchor a foreign-zone event to the device on any time edit.
|
||||||
*/
|
*/
|
||||||
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) {
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
@@ -31,22 +37,56 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa
|
|||||||
timezone = "UTC",
|
timezone = "UTC",
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val writeZone = writeZone(zone)
|
||||||
EventWriteTimes(
|
EventWriteTimes(
|
||||||
dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
dtStartMillis = start.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(),
|
dtEndMillis = end.toJavaLocalDateTime().atZone(writeZone).toInstant().toEpochMilli(),
|
||||||
timezone = zone.id,
|
timezone = writeZone.id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone this form's DTSTART is expressed in: UTC for an all-day event (the
|
||||||
|
* provider's date anchor), otherwise the form's own pinned zone, falling back to
|
||||||
|
* [deviceZone] when it doesn't pin one or pins something the tz database can't
|
||||||
|
* parse.
|
||||||
|
*/
|
||||||
|
private fun EventForm.writeZone(deviceZone: ZoneId): ZoneId = if (isAllDay) {
|
||||||
|
ZoneOffset.UTC
|
||||||
|
} else {
|
||||||
|
timezone?.let { runCatching { ZoneId.of(it) }.getOrNull() } ?: deviceZone
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form's start as a bare wall-clock value — what the user sees on the form,
|
||||||
|
* stripped of any zone. All-day events use their date's midnight rather than
|
||||||
|
* [EventForm.start]'s placeholder time-of-day, which exists only so switching the
|
||||||
|
* event back to timed has something to show.
|
||||||
|
*/
|
||||||
|
private fun EventForm.anchorLocal(): JavaLocalDateTime = if (isAllDay) {
|
||||||
|
start.date.toJavaLocalDate().atStartOfDay()
|
||||||
|
} else {
|
||||||
|
start.toJavaLocalDateTime()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC 2445 duration for a recurring event's row (the provider requires
|
* RFC 2445 duration for a recurring event's row (the provider requires
|
||||||
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
* DURATION instead of DTEND when an RRULE is set): whole days for all-day
|
||||||
* events, seconds otherwise.
|
* events, seconds otherwise.
|
||||||
*/
|
*/
|
||||||
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (isAllDay) {
|
internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String =
|
||||||
"P${(dtEndMillis - dtStartMillis) / MILLIS_PER_DAY}D"
|
rfc2445Duration(dtEndMillis - dtStartMillis, isAllDay)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 2445 duration for a [spanMillis]-long event: whole days for all-day
|
||||||
|
* events (the provider's convention), seconds otherwise. Shared by the write
|
||||||
|
* paths that need a DURATION but start from a raw millisecond span (the series
|
||||||
|
* copy and exception replay of a calendar move) rather than [EventWriteTimes].
|
||||||
|
*/
|
||||||
|
internal fun rfc2445Duration(spanMillis: Long, isAllDay: Boolean): String = if (isAllDay) {
|
||||||
|
"P${spanMillis / MILLIS_PER_DAY}D"
|
||||||
} else {
|
} else {
|
||||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
"P${spanMillis / 1_000L}S"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,10 +135,12 @@ internal fun buildEventInsertValues(
|
|||||||
* Time fields travel together (the provider validates them as a unit):
|
* Time fields travel together (the provider validates them as a unit):
|
||||||
* - unchanged times, all-day flag and rrule → no time columns at all;
|
* - unchanged times, all-day flag and rrule → no time columns at all;
|
||||||
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
* - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
|
||||||
* - recurring result → the *series* DTSTART moves by the same delta the user
|
* - recurring result → the *series* DTSTART moves by the same **wall-clock**
|
||||||
* applied to the displayed occurrence ([seriesDtStartMillis] is the row's
|
* shift the user applied to the displayed occurrence and is re-resolved in the
|
||||||
* current DTSTART), DURATION replaces DTEND, RRULE is written. This keeps
|
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
|
||||||
* past occurrences intact when someone edits a later occurrence's time.
|
* replaces DTEND, RRULE is written. This keeps past occurrences intact when
|
||||||
|
* someone edits a later occurrence's time, and keeps the anchor's time-of-day
|
||||||
|
* stable across a DST boundary or a zone change between the two.
|
||||||
*/
|
*/
|
||||||
internal fun buildEventUpdateValues(
|
internal fun buildEventUpdateValues(
|
||||||
original: EventForm,
|
original: EventForm,
|
||||||
@@ -125,10 +167,14 @@ internal fun buildEventUpdateValues(
|
|||||||
putAll(eventColorColumns(updated.colorKey, updated.color))
|
putAll(eventColorColumns(updated.colorKey, updated.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A zone change counts as a time change even when the wall-clock is
|
||||||
|
// untouched: the same 09:00 in another zone is a different instant, so
|
||||||
|
// DTSTART has to move with it.
|
||||||
val timesChanged = updated.start != original.start ||
|
val timesChanged = updated.start != original.start ||
|
||||||
updated.end != original.end ||
|
updated.end != original.end ||
|
||||||
updated.isAllDay != original.isAllDay ||
|
updated.isAllDay != original.isAllDay ||
|
||||||
updated.rrule != original.rrule
|
updated.rrule != original.rrule ||
|
||||||
|
updated.timezone != original.timezone
|
||||||
if (!timesChanged) return@buildMap
|
if (!timesChanged) return@buildMap
|
||||||
|
|
||||||
val newTimes = updated.toWriteTimes(zone)
|
val newTimes = updated.toWriteTimes(zone)
|
||||||
@@ -140,8 +186,23 @@ internal fun buildEventUpdateValues(
|
|||||||
put(CalendarContract.Events.RRULE, null)
|
put(CalendarContract.Events.RRULE, null)
|
||||||
put(CalendarContract.Events.DURATION, null)
|
put(CalendarContract.Events.DURATION, null)
|
||||||
} else {
|
} else {
|
||||||
val startDelta = newTimes.dtStartMillis - original.toWriteTimes(zone).dtStartMillis
|
// Move the series anchor by the *wall-clock* shift the user applied to the
|
||||||
put(CalendarContract.Events.DTSTART, seriesDtStartMillis + startDelta)
|
// displayed occurrence, then re-resolve it in the event's (possibly new)
|
||||||
|
// zone — never by a millisecond delta. An instant delta silently bakes in
|
||||||
|
// the offset that happened to apply on the edited occurrence's date, which
|
||||||
|
// is a different offset from the series anchor's whenever a DST boundary
|
||||||
|
// sits between them, or whenever the zone itself changed. Working in wall
|
||||||
|
// clock keeps "09:00" meaning 09:00 at both ends.
|
||||||
|
val seriesLocal = Instant.ofEpochMilli(seriesDtStartMillis)
|
||||||
|
.atZone(original.writeZone(zone)).toLocalDateTime()
|
||||||
|
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
|
||||||
|
val shifted = seriesLocal.plus(wallClockShift)
|
||||||
|
// An all-day series anchor must sit on a UTC midnight. A pure day move
|
||||||
|
// already lands there (both ends are midnights), but *switching* a
|
||||||
|
// recurring event to all-day shifts by a time-of-day too, so snap.
|
||||||
|
val newSeriesLocal = if (updated.isAllDay) shifted.toLocalDate().atStartOfDay() else shifted
|
||||||
|
val newSeriesStart = newSeriesLocal.atZone(updated.writeZone(zone))
|
||||||
|
put(CalendarContract.Events.DTSTART, newSeriesStart.toInstant().toEpochMilli())
|
||||||
put(CalendarContract.Events.DTEND, null)
|
put(CalendarContract.Events.DTEND, null)
|
||||||
put(CalendarContract.Events.RRULE, updated.rrule)
|
put(CalendarContract.Events.RRULE, updated.rrule)
|
||||||
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay))
|
||||||
@@ -182,6 +243,121 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
putAll(eventColorColumns(form.colorKey, form.color))
|
putAll(eventColorColumns(form.colorKey, form.color))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it
|
||||||
|
* verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
|
||||||
|
* is sync-adapter-owned and can't be updated in place). Recurring rows carry
|
||||||
|
* [rrule]/[duration] (and any [rdate]/[exdate]) with a null [dtEndMillis];
|
||||||
|
* one-off rows carry [dtEndMillis]. Colour is deliberately absent: a raw
|
||||||
|
* `EVENT_COLOR` or account-scoped `EVENT_COLOR_KEY` may be invalid on the target
|
||||||
|
* account, so the moved copy inherits the target calendar's colour instead.
|
||||||
|
*/
|
||||||
|
internal data class MasterEventSnapshot(
|
||||||
|
val title: String,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val dtEndMillis: Long?,
|
||||||
|
val duration: String?,
|
||||||
|
val rrule: String?,
|
||||||
|
val rdate: String?,
|
||||||
|
val exdate: String?,
|
||||||
|
val timezone: String?,
|
||||||
|
val availability: Int,
|
||||||
|
val accessLevel: Int,
|
||||||
|
val status: Int?,
|
||||||
|
val location: String?,
|
||||||
|
val description: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values re-creating [snapshot] as a fresh Events row on
|
||||||
|
* [targetCalendarId], keeping its [uid] so `.ics` backup dedup and sync identity
|
||||||
|
* survive the move. Preserves the recurrence skeleton (DTSTART/RRULE/DURATION,
|
||||||
|
* RDATE/EXDATE) so the series' generated instances — and therefore the
|
||||||
|
* ORIGINAL_INSTANCE_TIME of every copied exception — line up unchanged. The
|
||||||
|
* caller layers the user's field edits on top with a normal series update.
|
||||||
|
*/
|
||||||
|
internal fun buildMovedMasterValues(
|
||||||
|
snapshot: MasterEventSnapshot,
|
||||||
|
targetCalendarId: Long,
|
||||||
|
uid: String,
|
||||||
|
): Map<String, Any?> = buildMap {
|
||||||
|
put(CalendarContract.Events.CALENDAR_ID, targetCalendarId)
|
||||||
|
put(CalendarContract.Events.UID_2445, uid)
|
||||||
|
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||||
|
if (snapshot.rrule != null) {
|
||||||
|
put(CalendarContract.Events.RRULE, snapshot.rrule)
|
||||||
|
snapshot.rdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.RDATE, it) }
|
||||||
|
snapshot.exdate?.takeIf { it.isNotBlank() }?.let { put(CalendarContract.Events.EXDATE, it) }
|
||||||
|
put(CalendarContract.Events.DURATION, snapshot.movedDuration())
|
||||||
|
} else {
|
||||||
|
snapshot.dtEndMillis?.let { put(CalendarContract.Events.DTEND, it) }
|
||||||
|
}
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||||
|
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||||
|
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||||
|
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recurring copy's DURATION: its own if present, else derived from DTEND. */
|
||||||
|
private fun MasterEventSnapshot.movedDuration(): String = duration?.takeIf { it.isNotBlank() }
|
||||||
|
?: rfc2445Duration((dtEndMillis ?: dtStartMillis) - dtStartMillis, isAllDay)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw provider snapshot of one exception row of a recurring series (a modified
|
||||||
|
* or cancelled occurrence, `ORIGINAL_ID` = the series). [originalInstanceMillis]
|
||||||
|
* ties it to the occurrence it overrides; a [isCancelled] row only needs that.
|
||||||
|
*/
|
||||||
|
internal data class ExceptionRowSnapshot(
|
||||||
|
val exceptionEventId: Long,
|
||||||
|
val originalInstanceMillis: Long,
|
||||||
|
val isCancelled: Boolean,
|
||||||
|
val status: Int?,
|
||||||
|
val title: String,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val dtEndMillis: Long?,
|
||||||
|
val duration: String?,
|
||||||
|
val timezone: String?,
|
||||||
|
val availability: Int,
|
||||||
|
val accessLevel: Int,
|
||||||
|
val location: String?,
|
||||||
|
val description: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Column values replaying a *modified* occurrence [snapshot] against the moved
|
||||||
|
* series via `CONTENT_EXCEPTION_URI`. Like [buildOccurrenceExceptionValues] the
|
||||||
|
* length travels as DURATION (the provider rejects DTEND on an exception). A
|
||||||
|
* cancelled occurrence is written separately (ORIGINAL_INSTANCE_TIME +
|
||||||
|
* STATUS_CANCELED) — this builder is only for the modified case.
|
||||||
|
*/
|
||||||
|
internal fun buildCopiedExceptionValues(snapshot: ExceptionRowSnapshot): Map<String, Any?> =
|
||||||
|
buildMap {
|
||||||
|
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, snapshot.originalInstanceMillis)
|
||||||
|
put(CalendarContract.Events.TITLE, snapshot.title)
|
||||||
|
put(CalendarContract.Events.ALL_DAY, if (snapshot.isAllDay) 1 else 0)
|
||||||
|
put(CalendarContract.Events.DTSTART, snapshot.dtStartMillis)
|
||||||
|
put(
|
||||||
|
CalendarContract.Events.DURATION,
|
||||||
|
snapshot.duration?.takeIf { it.isNotBlank() }
|
||||||
|
?: rfc2445Duration(
|
||||||
|
(snapshot.dtEndMillis ?: snapshot.dtStartMillis) - snapshot.dtStartMillis,
|
||||||
|
snapshot.isAllDay,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
put(CalendarContract.Events.EVENT_TIMEZONE, snapshot.timezone ?: "UTC")
|
||||||
|
put(CalendarContract.Events.AVAILABILITY, snapshot.availability)
|
||||||
|
put(CalendarContract.Events.ACCESS_LEVEL, snapshot.accessLevel)
|
||||||
|
put(CalendarContract.Events.EVENT_LOCATION, snapshot.location?.ifEmpty { null })
|
||||||
|
put(CalendarContract.Events.DESCRIPTION, snapshot.description?.ifEmpty { null })
|
||||||
|
snapshot.status?.let { put(CalendarContract.Events.STATUS, it) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
||||||
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||||
@@ -209,6 +385,73 @@ internal fun buildOccurrenceCancelValues(
|
|||||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -21,9 +21,15 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
|
import java.time.ZoneId
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import java.time.temporal.WeekFields
|
import java.time.temporal.WeekFields
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -81,6 +87,24 @@ fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
|||||||
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolved first day of the week as a hot [StateFlow] — the one shape every
|
||||||
|
* screen that orders or lays out weekdays should use (the month grid, the agenda
|
||||||
|
* "this week" range, the recurrence weekday toggles). Sharing it keeps the
|
||||||
|
* initial-frame value identical everywhere; hand-rolled copies had drifted onto
|
||||||
|
* different `initialValue`s, so two surfaces could disagree for a frame.
|
||||||
|
*/
|
||||||
|
fun SettingsPrefs.firstDayOfWeek(scope: CoroutineScope): StateFlow<DayOfWeek> =
|
||||||
|
weekStart
|
||||||
|
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||||
|
.stateIn(
|
||||||
|
scope = scope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
// Seed with the locale convention rather than a hardcoded weekday, so
|
||||||
|
// the first frame is already right for everyone still on Auto.
|
||||||
|
initialValue = WeekStartPref.Auto.resolveFirstDay(Locale.getDefault()),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display settings (M4) persisted app-side: theme override, Material You
|
* Display settings (M4) persisted app-side: theme override, Material You
|
||||||
* dynamic colour, and week start. Language is handled separately through
|
* dynamic colour, and week start. Language is handled separately through
|
||||||
@@ -114,6 +138,21 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether raw provider colours are softened toward theme-fitting pastels
|
||||||
|
* before display (issue #36). Defaults to ON — the historical look, which
|
||||||
|
* caps harsh sync colours and pins brightness so entries read on both
|
||||||
|
* themes. Turning it off paints the calendar/event colour exactly as the
|
||||||
|
* sync source (DAVx5/CalDAV) publishes it.
|
||||||
|
*/
|
||||||
|
val softenCalendarColors: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[SOFTEN_COLORS_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSoftenCalendarColors(enabled: Boolean) {
|
||||||
|
store.edit { it[SOFTEN_COLORS_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
||||||
* strings — "system", "custom", or a bundled font's token — resolved to a
|
* strings — "system", "custom", or a bundled font's token — resolved to a
|
||||||
@@ -218,6 +257,33 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
|
store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Month view lays itself out (#38, #53). Defaults to [MonthViewStyle.Paged]
|
||||||
|
* — the historical behaviour, so existing installs see no change until they opt in.
|
||||||
|
*/
|
||||||
|
val monthViewStyle: Flow<MonthViewStyle> = store.data.map { prefs ->
|
||||||
|
prefs[MONTH_VIEW_STYLE_KEY].toEnum(MonthViewStyle.Paged)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMonthViewStyle(style: MonthViewStyle) {
|
||||||
|
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||||
|
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||||
|
* only while the view is off today. Turning it ON moves it to a persistent
|
||||||
|
* icon button in each calendar view's top bar (always shown, on today or
|
||||||
|
* not) and drops the FAB pill.
|
||||||
|
*/
|
||||||
|
val todayButtonInToolbar: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[TODAY_BUTTON_IN_TOOLBAR_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTodayButtonInToolbar(enabled: Boolean) {
|
||||||
|
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||||
@@ -252,6 +318,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the agenda (both the in-app screen and the home-screen widget)
|
||||||
|
* always anchors today at the top with a "nothing left today" placeholder,
|
||||||
|
* even once today has no remaining events (issue #35). Default ON — makes
|
||||||
|
* today's events easy to tell apart from a future day's at a glance.
|
||||||
|
*/
|
||||||
|
val agendaShowToday: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[AGENDA_SHOW_TODAY_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAgendaShowToday(enabled: Boolean) {
|
||||||
|
store.edit { it[AGENDA_SHOW_TODAY_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||||
* the historical hard-coded startup view — so existing users see no change
|
* the historical hard-coded startup view — so existing users see no change
|
||||||
@@ -325,6 +405,26 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zones the user picked recently, most recent first — the timezone picker's
|
||||||
|
* default list, since scrolling ~600 zones to re-find the two you actually
|
||||||
|
* use is the whole problem. Stored comma-joined (IANA ids never contain a
|
||||||
|
* comma); unparseable ids are dropped on read, so a zone the tz database
|
||||||
|
* later retires can't wedge the list.
|
||||||
|
*/
|
||||||
|
val recentTimeZones: Flow<List<String>> = store.data.map { prefs ->
|
||||||
|
parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addRecentTimeZone(zoneId: String) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val updated = (listOf(zoneId) + parseRecentTimeZones(prefs[RECENT_TIMEZONES_KEY]))
|
||||||
|
.distinct()
|
||||||
|
.take(MAX_RECENT_TIME_ZONES)
|
||||||
|
prefs[RECENT_TIMEZONES_KEY] = updated.joinToString(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether opening the new-event form focuses the title field and raises the
|
* Whether opening the new-event form focuses the title field and raises the
|
||||||
* keyboard straight away (issue #10). Default ON — a new event almost always
|
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||||
@@ -621,6 +721,13 @@ class SettingsPrefs @Inject constructor(
|
|||||||
private fun titleTemplateKey(type: SpecialDateType) =
|
private fun titleTemplateKey(type: SpecialDateType) =
|
||||||
stringPreferencesKey("special_dates_title_${type.name}")
|
stringPreferencesKey("special_dates_title_${type.name}")
|
||||||
|
|
||||||
|
private fun parseRecentTimeZones(stored: String?): List<String> =
|
||||||
|
stored?.split(',').orEmpty()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() && runCatching { ZoneId.of(it) }.isSuccess }
|
||||||
|
.distinct()
|
||||||
|
.take(MAX_RECENT_TIME_ZONES)
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -665,6 +772,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
companion object {
|
companion object {
|
||||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
|
internal val SOFTEN_COLORS_KEY = booleanPreferencesKey("soften_calendar_colors")
|
||||||
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||||
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
||||||
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
||||||
@@ -673,11 +781,15 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||||
|
internal val AGENDA_SHOW_TODAY_KEY =
|
||||||
|
booleanPreferencesKey("agenda_show_today")
|
||||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||||
|
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||||
|
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
@@ -704,6 +816,10 @@ class SettingsPrefs @Inject constructor(
|
|||||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
|
internal val RECENT_TIMEZONES_KEY = stringPreferencesKey("recent_time_zones")
|
||||||
|
|
||||||
|
/** Enough to cover the zones a user actually recurs to, without a wall of rows. */
|
||||||
|
internal const val MAX_RECENT_TIME_ZONES = 5
|
||||||
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||||
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||||
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||||
|
|||||||
@@ -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 = " – "
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import kotlinx.datetime.LocalDate
|
|||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User input for creating an event (and, from v1.3, editing one). Times are
|
* User input for creating an event (and, from v1.3, editing one). Times are
|
||||||
* wall-clock values in the device zone; the data layer translates them to
|
* wall-clock values in [timezone]; the data layer translates them to provider
|
||||||
* provider millis (all-day events normalise to UTC midnights there).
|
* millis (all-day events normalise to UTC midnights there).
|
||||||
*/
|
*/
|
||||||
data class EventForm(
|
data class EventForm(
|
||||||
val calendarId: Long?,
|
val calendarId: Long?,
|
||||||
@@ -18,6 +19,23 @@ data class EventForm(
|
|||||||
val isAllDay: Boolean = false,
|
val isAllDay: Boolean = false,
|
||||||
val start: LocalDateTime,
|
val start: LocalDateTime,
|
||||||
val end: LocalDateTime,
|
val end: LocalDateTime,
|
||||||
|
/**
|
||||||
|
* The zone [start]/[end] are wall-clock values in, or null to follow the
|
||||||
|
* device — null is not "no zone", it is "whichever zone the device is in
|
||||||
|
* when this is saved", which is what an event authored and lived in one
|
||||||
|
* place wants. The data layer resolves it at write time and always stamps a
|
||||||
|
* concrete `EVENT_TIMEZONE`.
|
||||||
|
*
|
||||||
|
* A non-null value pins the event to a zone regardless of where the device
|
||||||
|
* is, so it keeps tracking that zone's offset across DST. [toEditForm] only
|
||||||
|
* sets it when the stored zone differs from the device's, so merely opening
|
||||||
|
* a local event never reveals the field — and re-opening a pinned one in
|
||||||
|
* another zone round-trips it rather than silently re-anchoring it.
|
||||||
|
*
|
||||||
|
* Always null for all-day events: those are date-anchored, not zone-anchored
|
||||||
|
* (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
|
||||||
|
*/
|
||||||
|
val timezone: String? = null,
|
||||||
val location: String = "",
|
val location: String = "",
|
||||||
val description: String = "",
|
val description: String = "",
|
||||||
/** Reminder lead times in minutes before the start, deduplicated. */
|
/** Reminder lead times in minutes before the start, deduplicated. */
|
||||||
@@ -66,11 +84,19 @@ data class EventAttendee(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The form's optional sections. Which ones show by default is a user setting;
|
* The form's optional sections. Which ones show by default is a user setting;
|
||||||
* the rest unfold behind a "more fields" button.
|
* the rest unfold behind a "more fields" button. Declaration order is the order
|
||||||
|
* they're offered in, so a new constant goes where it belongs on the form, not
|
||||||
|
* at the end.
|
||||||
*/
|
*/
|
||||||
enum class EventFormField {
|
enum class EventFormField {
|
||||||
Location,
|
Location,
|
||||||
Description,
|
Description,
|
||||||
|
/**
|
||||||
|
* Pins the event's wall-clock times to a zone. Offered right after the time
|
||||||
|
* fields it qualifies, and suppressed entirely for all-day events, whose
|
||||||
|
* dates are deliberately zone-free.
|
||||||
|
*/
|
||||||
|
Timezone,
|
||||||
Reminders,
|
Reminders,
|
||||||
Recurrence,
|
Recurrence,
|
||||||
Availability,
|
Availability,
|
||||||
@@ -100,8 +126,25 @@ enum class EventFormProblem {
|
|||||||
* All-day provider times are UTC midnights with an exclusive end; the form
|
* All-day provider times are UTC midnights with an exclusive end; the form
|
||||||
* shows the last covered day and keeps placeholder wall-clock times in case
|
* shows the last covered day and keeps placeholder wall-clock times in case
|
||||||
* the user switches the event to timed.
|
* the user switches the event to timed.
|
||||||
|
*
|
||||||
|
* A timed event stored in a zone other than [zone] is prefilled *in its own
|
||||||
|
* zone* and keeps it pinned, so the wall-clock the form shows is the one the
|
||||||
|
* event means ("the New York 09:00 call") and a later save re-anchors it to the
|
||||||
|
* same zone rather than the device's.
|
||||||
*/
|
*/
|
||||||
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
|
||||||
|
// All-day events are date-anchored and carry a nominal "UTC" that is an
|
||||||
|
// anchor, not a location, so they never pin a zone.
|
||||||
|
val pinnedZone = if (instance.isAllDay) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
eventTimezone
|
||||||
|
?.takeIf { it != zone.id }
|
||||||
|
// An unparseable id (a malformed sync row) can't be honoured or
|
||||||
|
// shown; fall back to the device zone rather than failing the open.
|
||||||
|
?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
|
||||||
|
}
|
||||||
|
val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
|
||||||
val (start, end) = if (instance.isAllDay) {
|
val (start, end) = if (instance.isAllDay) {
|
||||||
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
val startDate = Instant.fromEpochMilliseconds(beginMillis)
|
||||||
.toLocalDateTime(TimeZone.UTC).date
|
.toLocalDateTime(TimeZone.UTC).date
|
||||||
@@ -110,8 +153,8 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
|
||||||
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
|
||||||
} else {
|
} else {
|
||||||
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(zone) to
|
Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
|
||||||
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(zone)
|
Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
|
||||||
}
|
}
|
||||||
return EventForm(
|
return EventForm(
|
||||||
calendarId = instance.calendarId,
|
calendarId = instance.calendarId,
|
||||||
@@ -119,6 +162,7 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
isAllDay = instance.isAllDay,
|
isAllDay = instance.isAllDay,
|
||||||
start = start,
|
start = start,
|
||||||
end = end,
|
end = end,
|
||||||
|
timezone = pinnedZone,
|
||||||
location = instance.location.orEmpty(),
|
location = instance.location.orEmpty(),
|
||||||
description = description.orEmpty(),
|
description = description.orEmpty(),
|
||||||
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
reminders = reminders.map { it.minutes }.distinct().sorted(),
|
||||||
@@ -176,6 +220,23 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
rowEnd = instance.end,
|
rowEnd = instance.end,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form's times as they land in [target] — what a pinned event's wall-clock
|
||||||
|
* actually means where the user is standing. Null when there's nothing to
|
||||||
|
* disambiguate: an unpinned event (already in [target]), one pinned to [target]
|
||||||
|
* itself, an all-day event (no zone), or an unparseable pinned zone.
|
||||||
|
*
|
||||||
|
* The form edits a pinned event in its own zone, so this is what lets the UI
|
||||||
|
* show the other side of the pair rather than making the user do the arithmetic.
|
||||||
|
*/
|
||||||
|
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
|
||||||
|
if (isAllDay) return null
|
||||||
|
val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
|
||||||
|
if (pinned.id == target.id) return null
|
||||||
|
return start.toInstant(pinned).toLocalDateTime(target) to
|
||||||
|
end.toInstant(pinned).toLocalDateTime(target)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The optional sections that hold a value in [form] — when editing, these
|
* The optional sections that hold a value in [form] — when editing, these
|
||||||
* must be visible regardless of the user's default-fields setting, or the
|
* must be visible regardless of the user's default-fields setting, or the
|
||||||
@@ -184,6 +245,7 @@ fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZon
|
|||||||
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
||||||
if (location.isNotBlank()) add(EventFormField.Location)
|
if (location.isNotBlank()) add(EventFormField.Location)
|
||||||
if (description.isNotBlank()) add(EventFormField.Description)
|
if (description.isNotBlank()) add(EventFormField.Description)
|
||||||
|
if (timezone != null) add(EventFormField.Timezone)
|
||||||
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
|
||||||
if (rrule != null) add(EventFormField.Recurrence)
|
if (rrule != null) add(EventFormField.Recurrence)
|
||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import java.text.Normalizer
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable zone, resolved for display at a given instant. [id] is the IANA
|
||||||
|
* id we store in `EVENT_TIMEZONE` and show as the primary label (via [label]);
|
||||||
|
* [shortName] its abbreviation ("CET", "WET", "UTC", or a "GMT+05:30" fallback);
|
||||||
|
* [offsetMinutes] its offset. Both the abbreviation and the offset shift with
|
||||||
|
* DST, so they're only meaningful next to the moment they were resolved for.
|
||||||
|
*
|
||||||
|
* [displayName] is the long localized name ("Central European Time"). It's kept
|
||||||
|
* for search only — matching on it lets someone type "pacific" — and is *not*
|
||||||
|
* displayed: spelled out next to the id it made the field too wide to fit.
|
||||||
|
*/
|
||||||
|
data class TimeZoneOption(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val shortName: String,
|
||||||
|
val offsetMinutes: Int,
|
||||||
|
) {
|
||||||
|
/** The id as the primary label, underscores undone ("America/New York"). */
|
||||||
|
val label: String get() = id.replace('_', ' ')
|
||||||
|
|
||||||
|
/** The trailing segment of the id ("Europe/Berlin" -> "Berlin"), underscores undone. */
|
||||||
|
val city: String get() = id.substringAfterLast('/').replace('_', ' ')
|
||||||
|
|
||||||
|
/** The leading segment ("Europe/Berlin" -> "Europe"); empty for bare ids like "UTC". */
|
||||||
|
val region: String get() = id.substringBeforeLast('/', missingDelimiterValue = "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four fields [filterTimeZones] matches on, normalized once here rather
|
||||||
|
* than per query. Normalizing is not cheap — NFD decomposition plus a combining-
|
||||||
|
* mark strip — and a search re-examines every option on every keystroke, so
|
||||||
|
* doing it at construction turns ~2400 normalizations per character into a few
|
||||||
|
* hundred plain `startsWith`/`contains` calls.
|
||||||
|
*
|
||||||
|
* Declared in the class body, so it stays out of `equals`/`hashCode`/`copy`:
|
||||||
|
* it is derived state, and two options with the same id are the same option.
|
||||||
|
*/
|
||||||
|
internal val searchKeys: TimeZoneSearchKeys = TimeZoneSearchKeys(
|
||||||
|
city = city.normalizeForSearch(),
|
||||||
|
id = id.normalizeForSearch(),
|
||||||
|
displayName = displayName.normalizeForSearch(),
|
||||||
|
shortName = shortName.normalizeForSearch(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pre-normalized match targets for one [TimeZoneOption]. */
|
||||||
|
internal data class TimeZoneSearchKeys(
|
||||||
|
val city: String,
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val shortName: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun optionFor(
|
||||||
|
zone: ZoneId,
|
||||||
|
locale: Locale,
|
||||||
|
at: Instant,
|
||||||
|
regionOf: (String) -> String?,
|
||||||
|
): TimeZoneOption = TimeZoneOption(
|
||||||
|
id = zone.id,
|
||||||
|
displayName = zone.getDisplayName(TextStyle.FULL, locale),
|
||||||
|
shortName = resolveAbbreviation(zone.id, zone.rules.isDaylightSavings(at), locale, regionOf),
|
||||||
|
offsetMinutes = zone.rules.getOffset(at).totalSeconds / 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone abbreviation ("EDT", "CEST"), DST-correct at the resolved instant, or
|
||||||
|
* a "GMT+05:30" form when no name exists.
|
||||||
|
*
|
||||||
|
* Two things make this fiddlier than it looks. First, it goes through
|
||||||
|
* java.util.TimeZone, NOT java.time's "zzz" formatter: on Android the latter has
|
||||||
|
* no short specific-zone names and degrades every zone to a "GMT-4" form (it
|
||||||
|
* agrees with java.util only on desktop, which is why desktop can't catch it).
|
||||||
|
* Second, ICU only surfaces the short name *commonly used in the display
|
||||||
|
* locale's region* — a German-region English phone (en-DE) is shown "CEST" but
|
||||||
|
* not "EDT", and an en-US phone the reverse. So ask in the display *language*
|
||||||
|
* but the *zone's* region ("America/New_York" in en-US, "Europe/Berlin" in
|
||||||
|
* en-DE) via [regionOf]. That covers almost everything; where a region still
|
||||||
|
* yields no name (Athens in en-GR) the plain device locale sometimes does, so
|
||||||
|
* try that next; failing both, the caller shows the offset.
|
||||||
|
*
|
||||||
|
* [regionOf] maps an IANA id to an ISO 3166 region and is injected because it
|
||||||
|
* needs `android.icu`, which this pure-JVM module can't import. The default
|
||||||
|
* (no region) leaves only the device-locale path — which is all a JVM test has
|
||||||
|
* anyway, and enough there since desktop ICU isn't region-gated the same way.
|
||||||
|
*/
|
||||||
|
private fun resolveAbbreviation(
|
||||||
|
id: String,
|
||||||
|
dst: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
regionOf: (String) -> String?,
|
||||||
|
): String {
|
||||||
|
fun shortIn(loc: Locale): String =
|
||||||
|
java.util.TimeZone.getTimeZone(id).getDisplayName(dst, java.util.TimeZone.SHORT, loc)
|
||||||
|
val regional = regionOf(id)
|
||||||
|
?.takeIf { it.length == 2 }
|
||||||
|
?.let { shortIn(Locale(locale.language, it)) }
|
||||||
|
if (regional != null && !regional.looksLikeOffset()) return regional
|
||||||
|
// Either no region, or the region has no name for this zone: the device
|
||||||
|
// locale is the next-best shot, and the offset the last resort.
|
||||||
|
return shortIn(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for the offset-style names ICU returns when a zone has no abbreviation. */
|
||||||
|
private fun String.looksLikeOffset(): Boolean = this == "UTC" || startsWith("GMT")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every zone the JVM knows, resolved at [at]. [regionOf] (see
|
||||||
|
* [resolveAbbreviation]) supplies the zone's region for the abbreviation.
|
||||||
|
*
|
||||||
|
* This is ~600 entries, each costing a localized display name plus up to two ICU
|
||||||
|
* short-name lookups, so it is **not** cheap enough for the main thread — build
|
||||||
|
* it off-thread once and filter the result with [filterTimeZones] rather than
|
||||||
|
* rebuilding per keystroke.
|
||||||
|
*
|
||||||
|
* Bare three-letter ids ("EST", "CST6CDT") and the legacy SystemV tree are
|
||||||
|
* dropped: they're aliases the tz database keeps for compatibility, they'd
|
||||||
|
* double up the real zones in the list, and none of them is what a user means
|
||||||
|
* when they pick a place.
|
||||||
|
*/
|
||||||
|
fun timeZoneOptions(
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
at: Instant = Instant.now(),
|
||||||
|
regionOf: (String) -> String? = { null },
|
||||||
|
): List<TimeZoneOption> = ZoneId.getAvailableZoneIds()
|
||||||
|
.asSequence()
|
||||||
|
.filter { it.contains('/') && !it.startsWith("SystemV/") }
|
||||||
|
.map { optionFor(ZoneId.of(it), locale, at, regionOf) }
|
||||||
|
.sortedWith(compareBy({ it.region }, { it.city }))
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a single [id] the same way [timeZoneOptions] would, or null if the tz
|
||||||
|
* database doesn't know it — for labelling one known zone without paying to
|
||||||
|
* build the whole catalogue.
|
||||||
|
*/
|
||||||
|
fun timeZoneOptionOf(
|
||||||
|
id: String,
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
at: Instant = Instant.now(),
|
||||||
|
regionOf: (String) -> String? = { null },
|
||||||
|
): TimeZoneOption? {
|
||||||
|
val zone = runCatching { ZoneId.of(id) }.getOrNull() ?: return null
|
||||||
|
return optionFor(zone, locale, at, regionOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The short descriptor shown beneath the id, e.g. "CET · GMT+01:00". "UTC" reads
|
||||||
|
* fine alone; any other offset-shaped name is normalised to our own GMT form so
|
||||||
|
* the offset isn't stated twice in ICU's spelling and ours.
|
||||||
|
*/
|
||||||
|
fun zoneDescriptor(option: TimeZoneOption): String {
|
||||||
|
val abbrev = option.shortName
|
||||||
|
return when {
|
||||||
|
abbrev == "UTC" -> "UTC"
|
||||||
|
abbrev.startsWith("GMT") -> formatGmtOffset(option.offsetMinutes)
|
||||||
|
else -> "$abbrev · ${formatGmtOffset(option.offsetMinutes)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [options] matching [query], best matches first; a blank query returns the list
|
||||||
|
* unchanged. Matching is accent- and case-insensitive and treats underscores as
|
||||||
|
* spaces, so "sao paulo" finds "America/Sao_Paulo".
|
||||||
|
*
|
||||||
|
* Ranking puts a city that *starts with* the query above one that merely
|
||||||
|
* contains it — typing "col" should reach Colombo before Turks_and_Caicos —
|
||||||
|
* and the id is matched ahead of the localized name so a user who knows the
|
||||||
|
* IANA id gets it first. An exact abbreviation hit ("CEST") ranks just under a
|
||||||
|
* city prefix, so typing an abbreviation surfaces every zone that shows it
|
||||||
|
* (all the CEST zones together); the abbreviation is the one resolved for the
|
||||||
|
* display region, i.e. what the row actually shows.
|
||||||
|
*/
|
||||||
|
fun filterTimeZones(options: List<TimeZoneOption>, query: String): List<TimeZoneOption> {
|
||||||
|
val needle = query.normalizeForSearch()
|
||||||
|
if (needle.isEmpty()) return options
|
||||||
|
return options
|
||||||
|
.mapNotNull { option ->
|
||||||
|
val keys = option.searchKeys
|
||||||
|
val rank = when {
|
||||||
|
keys.city.startsWith(needle) -> 0
|
||||||
|
keys.shortName == needle -> 1
|
||||||
|
keys.displayName.startsWith(needle) -> 2
|
||||||
|
keys.shortName.startsWith(needle) -> 3
|
||||||
|
keys.city.contains(needle) -> 4
|
||||||
|
keys.id.contains(needle) -> 5
|
||||||
|
keys.displayName.contains(needle) -> 6
|
||||||
|
else -> return@mapNotNull null
|
||||||
|
}
|
||||||
|
rank to option
|
||||||
|
}
|
||||||
|
.sortedWith(compareBy({ it.first }, { it.second.city }))
|
||||||
|
.map { it.second }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unicode combining marks — what NFD decomposition leaves an accent as. */
|
||||||
|
private val COMBINING_MARKS = Regex("\\p{Mn}+")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercased, accent-stripped, underscores and slashes flattened to spaces, so
|
||||||
|
* a query types the way a place is spoken rather than the way the tz database
|
||||||
|
* spells it.
|
||||||
|
*/
|
||||||
|
private fun String.normalizeForSearch(): String =
|
||||||
|
Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||||
|
.replace(COMBINING_MARKS, "")
|
||||||
|
.replace('_', ' ')
|
||||||
|
.replace('/', ' ')
|
||||||
|
.lowercase(Locale.ROOT)
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
/** "GMT+02:00" / "GMT-05:30" / "GMT" — the offset as shown next to a zone. */
|
||||||
|
fun formatGmtOffset(offsetMinutes: Int): String {
|
||||||
|
if (offsetMinutes == 0) return "GMT"
|
||||||
|
val sign = if (offsetMinutes < 0) '-' else '+'
|
||||||
|
val abs = kotlin.math.abs(offsetMinutes)
|
||||||
|
return "GMT%c%02d:%02d".format(sign, abs / 60, abs % 60)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -85,6 +88,8 @@ fun CalendarHost(
|
|||||||
// sensible non-empty initial values, so they're ready before the first frame.
|
// sensible non-empty initial values, so they're ready before the first frame.
|
||||||
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||||
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||||
|
// Whether the jump-to-today control sits in each view's top bar or the FAB (#60).
|
||||||
|
val todayInToolbar = viewModel.todayButtonInToolbar.collectAsStateWithLifecycle().value
|
||||||
|
|
||||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
@@ -172,6 +177,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 +198,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 +216,20 @@ fun CalendarHost(
|
|||||||
importForm = null
|
importForm = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
|
||||||
|
// widget) opens the occurrence straight in the edit form. Drop any covering
|
||||||
|
// overlay first — the edit overlay sits below Settings/import in the Box, so
|
||||||
|
// without this it would open hidden underneath them. Same held-key pattern as
|
||||||
|
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
|
||||||
|
LaunchedEffect(requestedEditKey) {
|
||||||
|
if (requestedEditKey != null) {
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
heldEditKey = requestedEditKey
|
||||||
|
editKey = requestedEditKey
|
||||||
|
onEditKeyConsumed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A home-screen widget launch asks to open a date (→ day view), open an
|
// A home-screen widget launch asks to open a date (→ day view), open an
|
||||||
// event's detail, or start a create. Handled once and cleared, mirroring
|
// event's detail, or start a create. Handled once and cleared, mirroring
|
||||||
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
// [requestedDetailKey]. Date/event opens root the stack in the widget's own
|
||||||
@@ -297,6 +320,7 @@ fun CalendarHost(
|
|||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -308,16 +332,19 @@ fun CalendarHost(
|
|||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -329,6 +356,7 @@ fun CalendarHost(
|
|||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
quickSwitchViews = quickSwitchViews,
|
quickSwitchViews = quickSwitchViews,
|
||||||
drawerViewOrder = drawerViewOrder,
|
drawerViewOrder = drawerViewOrder,
|
||||||
|
todayInToolbar = todayInToolbar,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,6 +391,14 @@ fun CalendarHost(
|
|||||||
heldEditKey = key
|
heldEditKey = key
|
||||||
editKey = key
|
editKey = key
|
||||||
},
|
},
|
||||||
|
onDuplicate = { form ->
|
||||||
|
// Reuse the prefilled-create overlay: a duplicate is a
|
||||||
|
// fresh event, so it applies the default reminder like an
|
||||||
|
// in-app new event (#52).
|
||||||
|
importFormSource = ImportSource.Insert
|
||||||
|
importForm = form
|
||||||
|
detailKey = null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,6 +471,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 +480,7 @@ fun CalendarHost(
|
|||||||
EventEditScreen(
|
EventEditScreen(
|
||||||
initialDateIso = null,
|
initialDateIso = null,
|
||||||
initialForm = form,
|
initialForm = form,
|
||||||
|
initialFormSource = importFormSource,
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { importForm = null },
|
onSaved = { importForm = null },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,4 +45,12 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = IMPLEMENTED_VIEWS,
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Whether each view's jump-to-today control lives in the top bar, not the FAB (#60). */
|
||||||
|
val todayButtonInToolbar: StateFlow<Boolean> = prefs.todayButtonInToolbar
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.floret.locale.localizedDateFormatter
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,10 +86,12 @@ fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The concrete span the range covers, starting at [start] through [end]
|
* The concrete span the range covers, starting at [start] through [end]
|
||||||
* (inclusive), for a human-readable header:
|
* (inclusive), for a human-readable header. Both ends use the same day-month
|
||||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
* order so a span never mixes "15 Jul" with "Aug 13, 2026":
|
||||||
|
* - [AgendaRange.Day] → a single date ("27 Jun 2026")
|
||||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
* - everything else → "start – end" ("27 Jun – 3 Jul 2026"), with the start's
|
||||||
|
* year shown too only when it differs from the end's.
|
||||||
*/
|
*/
|
||||||
fun agendaRangeWindowSummary(
|
fun agendaRangeWindowSummary(
|
||||||
range: AgendaRange,
|
range: AgendaRange,
|
||||||
@@ -100,11 +101,15 @@ fun agendaRangeWindowSummary(
|
|||||||
): String {
|
): String {
|
||||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dayMonth = localizedDateFormatter(locale, "dMMM")
|
||||||
|
val dayMonthYear = localizedDateFormatter(locale, "dMMMy")
|
||||||
return when (range) {
|
return when (range) {
|
||||||
AgendaRange.Day -> medium.format(javaStart)
|
AgendaRange.Day -> dayMonthYear.format(javaStart)
|
||||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
AgendaRange.ThisMonth -> localizedDateFormatter(locale, "LLLLy").format(javaStart)
|
||||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
else -> {
|
||||||
|
val startFmt = if (start.year == end.year) dayMonth else dayMonthYear
|
||||||
|
"${startFmt.format(javaStart)} – ${dayMonthYear.format(javaEnd)}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
// The agenda's row vocabulary, split out of AgendaScreen so the month view's
|
||||||
|
// split style can list a day with exactly the same visual language instead of
|
||||||
|
// growing a parallel set of event rows.
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaDayHeader(
|
||||||
|
date: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpenDay(date) },
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = agendaDayLabel(date, today),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = if (date == today) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card standing in for a day with no events — the same coffee-cup motif as the
|
||||||
|
* agenda's full-screen empty state, boxed into a card so the day keeps a visible slot
|
||||||
|
* rather than a bare header. Used for an anchored, event-less today (#35) and for
|
||||||
|
* an empty selected day in the month view's split style.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaEmptyDayRow(text: String, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
// Match a single event row's resting corner radius (floret groupedShape).
|
||||||
|
shape = RoundedCornerShape(22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 18.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Coffee,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaEventRow(
|
||||||
|
event: EventInstance,
|
||||||
|
day: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
position: Position,
|
||||||
|
dimmed: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
GroupedRow(
|
||||||
|
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
|
title = title,
|
||||||
|
summary = agendaTimeSummary(event, day, zone),
|
||||||
|
position = position,
|
||||||
|
minHeight = 64.dp,
|
||||||
|
leading = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 6.dp, height = 36.dp)
|
||||||
|
.clip(RoundedCornerShape(3.dp))
|
||||||
|
.background(eventFill(event.color, dark, soften)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
||||||
|
@Composable
|
||||||
|
internal fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
||||||
|
val relative = when (date) {
|
||||||
|
today -> stringResource(R.string.agenda_header_today)
|
||||||
|
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val formatted = formatAgendaDate(date)
|
||||||
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time line under the title: "09:00 – 10:00 · Location", "All day", etc.
|
||||||
|
*
|
||||||
|
* A multi-day event shows only the part relevant to [day], spelled out so each
|
||||||
|
* day reads on its own: its first day names the start ("Starts 14:00"), its last
|
||||||
|
* day the end ("Ends 10:00"), and any whole day in between reads as "All day".
|
||||||
|
* An all-day multi-day event is simply "All day" on every day it covers.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun agendaTimeSummary(event: EventInstance, day: LocalDate, zone: TimeZone): String {
|
||||||
|
val is24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
|
||||||
|
val time = when (val label = agendaTimeLabel(event, day, zone)) {
|
||||||
|
AgendaTimeLabel.AllDay -> stringResource(R.string.event_detail_all_day)
|
||||||
|
is AgendaTimeLabel.Starts -> stringResource(
|
||||||
|
R.string.agenda_span_starts,
|
||||||
|
formatTime(label.start, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
is AgendaTimeLabel.Ends -> stringResource(
|
||||||
|
R.string.agenda_span_ends,
|
||||||
|
formatTime(label.end, zone, is24Hour, locale),
|
||||||
|
)
|
||||||
|
is AgendaTimeLabel.Range -> "${formatTime(label.start, zone, is24Hour, locale)} – " +
|
||||||
|
formatTime(label.end, zone, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
|
return if (location != null) "$time · $location" else time
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(
|
||||||
|
instant: Instant,
|
||||||
|
zone: TimeZone,
|
||||||
|
is24Hour: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
val t = instant.toLocalDateTime(zone).time
|
||||||
|
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
|
// Weekday + date in the locale's own field order (e.g. "Wed, Jun 17, 2026"
|
||||||
|
// vs "Mi., 17. Juni 2026") rather than a hardcoded day-month-year layout.
|
||||||
|
return localizedDateFormatter(locale, "EEEdMMMy").format(java)
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.agenda
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -15,23 +12,21 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Coffee
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
import androidx.compose.material.icons.filled.DateRange
|
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
@@ -45,8 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -62,31 +55,23 @@ import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
|||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
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.floret.components.pastelize
|
|
||||||
import de.jeanlucmakiola.floret.components.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.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
// No file-level zone constant here on purpose: it would be fixed for the process
|
||||||
|
// lifetime and drift from the zone AgendaViewModel groups in after a device
|
||||||
|
// time-zone change. The zone travels on AgendaUiState.Success instead.
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -100,12 +85,14 @@ fun AgendaScreen(
|
|||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -148,12 +135,14 @@ fun AgendaScreen(
|
|||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = viewModel::goToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnToday,
|
todayVisible = !isOnToday && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.agenda_today_action),
|
todayText = stringResource(R.string.agenda_today_action),
|
||||||
onToday = viewModel::goToToday,
|
onToday = viewModel::goToToday,
|
||||||
onCreate = { onCreateEvent(anchor, null) },
|
onCreate = { onCreateEvent(anchor, null) },
|
||||||
@@ -170,9 +159,11 @@ fun AgendaScreen(
|
|||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
// end aligns the selector's right edge with the top-bar view
|
||||||
|
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
AgendaRangeBanner(
|
AgendaRangeBanner(
|
||||||
range = s.range,
|
range = s.range,
|
||||||
@@ -190,6 +181,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 +205,11 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
* The agenda's current range, tapped to open the range picker as a session-only
|
||||||
* range picker as a session-only override. Filled with the primary container
|
* override. Shares the top-bar view switcher's button shape so the two read as a
|
||||||
* while an override is active, so the temporary state is obvious.
|
* family, but stays low-emphasis — a subtle neutral surface tint rather than the
|
||||||
|
* switcher's secondary container, so it doesn't compete. Fills with the primary
|
||||||
|
* container only while an override is active, to make that temporary state clear.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaRangePill(
|
private fun AgendaRangePill(
|
||||||
@@ -224,43 +218,34 @@ private fun AgendaRangePill(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val container = if (isOverride) {
|
FilledTonalButton(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
|
containerColor = if (isOverride) {
|
||||||
MaterialTheme.colorScheme.primaryContainer
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
}
|
},
|
||||||
val content = if (isOverride) {
|
contentColor = if (isOverride) {
|
||||||
MaterialTheme.colorScheme.onPrimaryContainer
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
},
|
||||||
Surface(
|
),
|
||||||
color = container,
|
modifier = modifier,
|
||||||
contentColor = content,
|
|
||||||
shape = RoundedCornerShape(50),
|
|
||||||
modifier = modifier.clickable(onClick = onClick),
|
|
||||||
) {
|
) {
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.DateRange,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = agendaRangeLabel(range),
|
text = agendaRangeLabel(range),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
* A header naming the concrete window currently shown under a "showing …" label,
|
||||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||||
|
* lives on the selector button beside it, so it isn't repeated here.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaRangeBanner(
|
private fun AgendaRangeBanner(
|
||||||
@@ -277,8 +262,10 @@ private fun AgendaRangeBanner(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
// Just the concrete dates — the range's name ("Next 30 days") already
|
||||||
|
// sits on the selector button to the right, so repeating it here is noise.
|
||||||
Text(
|
Text(
|
||||||
text = "${agendaRangeLabel(range)}, $window",
|
text = window,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
@@ -289,6 +276,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 +292,7 @@ private fun AgendaContent(
|
|||||||
// Hiding drops finished events — and any day they leave empty; dimming
|
// Hiding drops finished events — and any day they leave empty; dimming
|
||||||
// keeps them but fades the row. Recomputed each minute so events fall
|
// keeps them but fades the row. Recomputed each minute so events fall
|
||||||
// away (or fade) as they end while the screen stays open.
|
// away (or fade) as they end while the screen stays open.
|
||||||
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
val filtered = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
state.days.mapNotNull { day ->
|
state.days.mapNotNull { day ->
|
||||||
val remaining = day.events.filterNot { it.hasEnded(now) }
|
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||||
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
@@ -312,12 +300,21 @@ private fun AgendaContent(
|
|||||||
} else {
|
} else {
|
||||||
state.days
|
state.days
|
||||||
}
|
}
|
||||||
|
// Anchor today with a "nothing left today" placeholder — but only when
|
||||||
|
// the window actually starts on today; a jumped-to date has no today in
|
||||||
|
// it, so anchoring there would be misleading (#35).
|
||||||
|
val days = anchorTodayIfMissing(
|
||||||
|
days = filtered,
|
||||||
|
today = state.today,
|
||||||
|
enabled = showToday && state.anchor == state.today,
|
||||||
|
)
|
||||||
if (days.isEmpty()) {
|
if (days.isEmpty()) {
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(
|
AgendaList(
|
||||||
days = days,
|
days = days,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
zone = state.zone,
|
||||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
now = now,
|
now = now,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
@@ -334,6 +331,7 @@ private fun AgendaContent(
|
|||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
days: List<AgendaDay>,
|
days: List<AgendaDay>,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
dimPast: Boolean,
|
dimPast: Boolean,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
@@ -349,76 +347,38 @@ 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)
|
||||||
}
|
}
|
||||||
|
if (day.events.isEmpty()) {
|
||||||
|
// An anchored, event-less today (#35) — "nothing left today".
|
||||||
|
item(key = "placeholder-${day.date}") {
|
||||||
|
AgendaEmptyDayRow(
|
||||||
|
text = stringResource(R.string.agenda_no_more_today),
|
||||||
|
onClick = { onOpenDay(day.date) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = day.events,
|
items = day.events,
|
||||||
key = { _, event -> event.instanceId },
|
// Scope the key by day: a multi-day event appears under every
|
||||||
|
// day it spans, so its instanceId alone is not unique across
|
||||||
|
// the list (LazyColumn requires unique keys).
|
||||||
|
key = { _, event -> "${day.date}-${event.instanceId}" },
|
||||||
) { index, event ->
|
) { index, event ->
|
||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
|
day = day.date,
|
||||||
|
zone = zone,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
dimmed = dimPast && event.hasEnded(now),
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
modifier = animateItemMotion(),
|
modifier = animateItemMotion(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AgendaDayHeader(
|
|
||||||
date: LocalDate,
|
|
||||||
today: LocalDate,
|
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onOpenDay(date) },
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = agendaDayLabel(date, today),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = if (date == today) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AgendaEventRow(
|
|
||||||
event: EventInstance,
|
|
||||||
position: Position,
|
|
||||||
dimmed: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
val dark = isSystemInDarkTheme()
|
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
|
||||||
GroupedRow(
|
|
||||||
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
|
||||||
title = title,
|
|
||||||
summary = agendaTimeSummary(event),
|
|
||||||
position = position,
|
|
||||||
minHeight = 64.dp,
|
|
||||||
leading = {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(width = 6.dp, height = 36.dp)
|
|
||||||
.clip(RoundedCornerShape(3.dp))
|
|
||||||
.background(pastelize(event.color, dark)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||||
Column(
|
Column(
|
||||||
@@ -448,6 +408,8 @@ private fun AgendaTopBar(
|
|||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -466,6 +428,7 @@ private fun AgendaTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -485,42 +448,3 @@ private fun AgendaTopBar(
|
|||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */
|
|
||||||
@Composable
|
|
||||||
private fun agendaDayLabel(date: LocalDate, today: LocalDate): String {
|
|
||||||
val relative = when (date) {
|
|
||||||
today -> stringResource(R.string.agenda_header_today)
|
|
||||||
today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val formatted = formatAgendaDate(date)
|
|
||||||
return if (relative != null) "$relative · $formatted" else formatted
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */
|
|
||||||
@Composable
|
|
||||||
private fun agendaTimeSummary(event: EventInstance): String {
|
|
||||||
val time = if (event.isAllDay) {
|
|
||||||
stringResource(R.string.event_detail_all_day)
|
|
||||||
} else {
|
|
||||||
val is24Hour = LocalUse24HourFormat.current
|
|
||||||
val locale = currentLocale()
|
|
||||||
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
|
||||||
}
|
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
|
||||||
return if (location != null) "$time · $location" else time
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
|
|
||||||
val t = instant.toLocalDateTime(zone).time
|
|
||||||
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatAgendaDate(date: LocalDate): String {
|
|
||||||
val locale = Locale.getDefault()
|
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
|
||||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,9 +2,70 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone the event's calendar dates live in. Timed events are resolved in the
|
||||||
|
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
||||||
|
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
|
||||||
|
* a one-day event onto its next day. Matches the Week view and detail card.
|
||||||
|
*/
|
||||||
|
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||||
|
if (isAllDay) TimeZone.UTC else zone
|
||||||
|
|
||||||
|
/** The first calendar day this event occupies. */
|
||||||
|
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||||
|
start.toLocalDateTime(dateZone(zone)).date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last calendar day this event actually occupies. An event ending exactly at
|
||||||
|
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
||||||
|
* into that boundary day, so resolve the instant just before [end].
|
||||||
|
*/
|
||||||
|
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||||
|
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||||
|
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||||
|
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||||
|
spanFirstDay(zone) != spanLastDay(zone)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an agenda row's time line should convey for an event on a given day —
|
||||||
|
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||||
|
* agenda screen and the agenda widget label multi-day events identically; each
|
||||||
|
* surface only formats the instants into its own locale/24h string.
|
||||||
|
*/
|
||||||
|
sealed interface AgendaTimeLabel {
|
||||||
|
/** An all-day event, or a whole in-between day of a multi-day span. */
|
||||||
|
data object AllDay : AgendaTimeLabel
|
||||||
|
/** The first day of a multi-day timed event: when it begins. */
|
||||||
|
data class Starts(val start: Instant) : AgendaTimeLabel
|
||||||
|
/** The last day of a multi-day timed event: when it ends. */
|
||||||
|
data class Ends(val end: Instant) : AgendaTimeLabel
|
||||||
|
/** A single-day timed event: its start–end range. */
|
||||||
|
data class Range(val start: Instant, val end: Instant) : AgendaTimeLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The [AgendaTimeLabel] for [event] as it appears on [day], resolved in [zone]. */
|
||||||
|
fun agendaTimeLabel(event: EventInstance, day: LocalDate, zone: TimeZone): AgendaTimeLabel {
|
||||||
|
if (event.isAllDay) return AgendaTimeLabel.AllDay
|
||||||
|
val firstDay = event.spanFirstDay(zone)
|
||||||
|
val lastDay = event.spanLastDay(zone)
|
||||||
|
return when {
|
||||||
|
firstDay == lastDay -> AgendaTimeLabel.Range(event.start, event.end)
|
||||||
|
day <= firstDay -> AgendaTimeLabel.Starts(event.start)
|
||||||
|
day >= lastDay -> AgendaTimeLabel.Ends(event.end)
|
||||||
|
else -> AgendaTimeLabel.AllDay // a full in-between day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One calendar day with at least one event, for the agenda list. */
|
/** One calendar day with at least one event, for the agenda list. */
|
||||||
data class AgendaDay(
|
data class AgendaDay(
|
||||||
@@ -15,22 +76,41 @@ data class AgendaDay(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
* Group flat [instances] into forward-looking [AgendaDay]s (only days that
|
||||||
* actually carry events). An event that began before [anchor] (ongoing or
|
* actually carry events). A multi-day event surfaces on *every* day it spans,
|
||||||
* multi-day) is clamped to the anchor day so it still surfaces on top. Within a
|
* not just its first — clamped to [[anchor], [windowEnd]] so an event that began
|
||||||
* day, all-day events sort first, then ascending by start time, then title.
|
* before the window (ongoing) still lists from the anchor day, and one running
|
||||||
|
* past the window stops at the last visible day. Within a day, all-day events
|
||||||
|
* sort first, then ascending by start time, then title.
|
||||||
*
|
*
|
||||||
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
* Shared by the Agenda screen and the agenda home-screen widget so both group
|
||||||
* and order identically.
|
* and order identically.
|
||||||
*/
|
*/
|
||||||
fun groupAgendaDays(
|
fun groupAgendaDays(
|
||||||
anchor: LocalDate,
|
anchor: LocalDate,
|
||||||
|
windowEnd: LocalDate,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
): List<AgendaDay> =
|
): List<AgendaDay> {
|
||||||
instances
|
val byDay = sortedMapOf<LocalDate, MutableList<EventInstance>>()
|
||||||
.groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) }
|
for (instance in instances) {
|
||||||
.toSortedMap()
|
val firstDay = instance.spanFirstDay(zone).coerceAtLeast(anchor)
|
||||||
.map { (date, dayEvents) ->
|
val lastDay = instance.spanLastDay(zone).coerceAtMost(windowEnd)
|
||||||
|
// Skip instances that don't actually occupy any day in [[anchor], [windowEnd]].
|
||||||
|
// The provider returns an event whenever its instant span overlaps the query
|
||||||
|
// window, but all-day events live at UTC midnights with an exclusive end: east
|
||||||
|
// of UTC that end dips just past local midnight, so *yesterday's* all-day event
|
||||||
|
// overlaps today's window start and comes back even though its true last day
|
||||||
|
// (resolved in UTC) is before the anchor. Clamping it up to the anchor would
|
||||||
|
// surface it under "today" (issue #65); drop it instead. The symmetric case —
|
||||||
|
// a next-day all-day event overlapping the window's last instant — drops too.
|
||||||
|
if (lastDay < firstDay) continue
|
||||||
|
var day = firstDay
|
||||||
|
while (day <= lastDay) {
|
||||||
|
byDay.getOrPut(day) { mutableListOf() }.add(instance)
|
||||||
|
day = day.plus(1, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return byDay.map { (date, dayEvents) ->
|
||||||
AgendaDay(
|
AgendaDay(
|
||||||
date = date,
|
date = date,
|
||||||
events = dayEvents.sortedWith(
|
events = dayEvents.sortedWith(
|
||||||
@@ -40,6 +120,25 @@ fun groupAgendaDays(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure [today] surfaces as the first agenda day even when it carries no
|
||||||
|
* (remaining) events, by prepending an empty-event [AgendaDay] the agenda widget
|
||||||
|
* renders as a "nothing left today" placeholder. A no-op unless [enabled], and
|
||||||
|
* when today already has its own day in [days]. Keeps today anchored at the top
|
||||||
|
* so a glance tells today's events apart from a future day's (issue #35).
|
||||||
|
*/
|
||||||
|
fun anchorTodayIfMissing(
|
||||||
|
days: List<AgendaDay>,
|
||||||
|
today: LocalDate,
|
||||||
|
enabled: Boolean,
|
||||||
|
): List<AgendaDay> =
|
||||||
|
if (enabled && days.none { it.date == today }) {
|
||||||
|
listOf(AgendaDay(today, emptyList())) + days
|
||||||
|
} else {
|
||||||
|
days
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
* State for the Agenda view: a flat, forward-looking list of upcoming events
|
||||||
@@ -61,5 +160,12 @@ sealed interface AgendaUiState {
|
|||||||
val rangeEnd: LocalDate,
|
val rangeEnd: LocalDate,
|
||||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||||
val showRangeBar: Boolean,
|
val showRangeBar: Boolean,
|
||||||
|
/**
|
||||||
|
* The zone [days] were grouped in. Carried in the state rather than
|
||||||
|
* re-read by the screen so labelling and grouping cannot disagree: an
|
||||||
|
* event's "Starts …/Ends …/All day" line is only correct relative to the
|
||||||
|
* same zone that decided which day it was filed under.
|
||||||
|
*/
|
||||||
|
val zone: TimeZone,
|
||||||
) : AgendaUiState
|
) : AgendaUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -20,13 +20,11 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import java.util.Locale
|
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
@@ -51,8 +49,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||||
|
|
||||||
// First day of the week, for the calendar-aligned "this week" range.
|
// First day of the week, for the calendar-aligned "this week" range.
|
||||||
private val weekStartDay = settingsPrefs.weekStart
|
private val weekStartDay = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How to treat events that already ended today (show / dim / hide). A display
|
* How to treat events that already ended today (show / dim / hide). A display
|
||||||
@@ -66,6 +63,19 @@ class AgendaViewModel @Inject constructor(
|
|||||||
initialValue = PastEventDisplay.SHOW,
|
initialValue = PastEventDisplay.SHOW,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to keep today anchored at the top with a "nothing left today"
|
||||||
|
* placeholder even once it has no remaining events (#35). A display concern
|
||||||
|
* applied in the composition (after past-event filtering), so it rides
|
||||||
|
* alongside the data state like [pastEventDisplay] rather than re-querying.
|
||||||
|
*/
|
||||||
|
val showToday: StateFlow<Boolean> = settingsPrefs.agendaShowToday
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = true,
|
||||||
|
)
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
@@ -146,11 +156,11 @@ class AgendaViewModel @Inject constructor(
|
|||||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
val anchor = params.anchor
|
val anchor = params.anchor
|
||||||
val days = groupAgendaDays(anchor, instances, zone)
|
|
||||||
val rangeEnd = anchor.plus(
|
val rangeEnd = anchor.plus(
|
||||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
|
val days = groupAgendaDays(anchor, rangeEnd, instances, zone)
|
||||||
return AgendaUiState.Success(
|
return AgendaUiState.Success(
|
||||||
anchor = anchor,
|
anchor = anchor,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
@@ -159,6 +169,7 @@ class AgendaViewModel @Inject constructor(
|
|||||||
rangeIsOverride = params.rangeIsOverride,
|
rangeIsOverride = params.rangeIsOverride,
|
||||||
rangeEnd = rangeEnd,
|
rangeEnd = rangeEnd,
|
||||||
showRangeBar = params.showRangeBar,
|
showRangeBar = params.showRangeBar,
|
||||||
|
zone = zone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ 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.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
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
|
||||||
@@ -108,7 +110,6 @@ import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
|||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
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". */
|
||||||
@@ -565,6 +566,7 @@ private fun CalendarEditor(
|
|||||||
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
|
||||||
var confirmDelete by remember { mutableStateOf(false) }
|
var confirmDelete by remember { mutableStateOf(false) }
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -624,7 +626,7 @@ private fun CalendarEditor(
|
|||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = pastelize(color, dark)) {
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
|
|||||||
@@ -14,16 +14,17 @@ 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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||||
* in the calendar's (pastelised) colour. Shared by the calendar manager and the
|
* in the calendar's colour — softened to a pastel, or raw when the softener is
|
||||||
* visibility filter so they read identically.
|
* off (issue #36). Shared by the calendar manager and the visibility filter so
|
||||||
|
* they read identically.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
@@ -34,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.CalendarMonth,
|
Icons.Filled.CalendarMonth,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = pastelize(color, dark),
|
tint = eventFill(color, dark, soften),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
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.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -33,6 +32,7 @@ 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.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's single "which calendar" selection list, shared by the event editor
|
* The app's single "which calendar" selection list, shared by the event editor
|
||||||
@@ -99,13 +99,7 @@ private fun CalendarPickerGroup(
|
|||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
trailing = if (isSelected) {
|
trailing = if (isSelected) {
|
||||||
{
|
{ SelectedCheck() }
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag distance that turns a calendar page, shared by the month, week and day
|
||||||
|
* views so all three answer a swipe at the same point.
|
||||||
|
*
|
||||||
|
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
|
||||||
|
* changed the month, and a page that turns on an unintended gesture reads as the
|
||||||
|
* animation misfiring rather than as the gesture being over-eager.
|
||||||
|
*/
|
||||||
|
val CALENDAR_SWIPE_THRESHOLD = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole-page horizontal swipe: one page per gesture, committed **the moment
|
||||||
|
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
|
||||||
|
*
|
||||||
|
* Waiting for the lift meant the page sat still under a finger that had already
|
||||||
|
* travelled far enough to ask for it, and the answer only arrived once you let
|
||||||
|
* go — which reads as the view being slow rather than as a deliberate commit.
|
||||||
|
* Firing on the threshold is what makes the gesture feel like it is being
|
||||||
|
* followed. The trade is that a drag can no longer be taken back by dragging the
|
||||||
|
* other way; in practice, once you have moved 24dp deliberately you meant it, and
|
||||||
|
* the page you land on is one swipe back.
|
||||||
|
*
|
||||||
|
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
|
||||||
|
* underneath this, and a two-dimensional detector here would claim those drags
|
||||||
|
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
|
||||||
|
* detector's slop while a vertical one is consumed below, and the two coexist.
|
||||||
|
* (The month view's split style needs a vertical axis as well, so it keeps its
|
||||||
|
* own axis-locking detector rather than using this.)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberCalendarPageSwipe(
|
||||||
|
onSwipeNext: () -> Unit,
|
||||||
|
onSwipePrev: () -> Unit,
|
||||||
|
): Modifier {
|
||||||
|
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
|
||||||
|
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
|
||||||
|
var accum = 0f
|
||||||
|
// One page per gesture: without this a long drag would keep re-firing
|
||||||
|
// every time the accumulator crossed the threshold again.
|
||||||
|
var fired = false
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
accum = 0f
|
||||||
|
fired = false
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
accum = 0f
|
||||||
|
fired = false
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
accum = 0f
|
||||||
|
fired = false
|
||||||
|
},
|
||||||
|
onHorizontalDrag = { _, drag ->
|
||||||
|
accum += drag
|
||||||
|
if (!fired) {
|
||||||
|
val commit = when {
|
||||||
|
accum < -threshold -> onSwipeNext
|
||||||
|
accum > threshold -> onSwipePrev
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (commit != null) {
|
||||||
|
fired = true
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a calendar title (top bar or widget header): [skeleton]'s fields laid
|
||||||
|
* out in [locale]'s own order, with the year shown only when [date] falls outside
|
||||||
|
* [currentYear].
|
||||||
|
*
|
||||||
|
* Pass [skeleton] *without* a year field — "LLLL" for a month, "EEEdMMM" for a
|
||||||
|
* day. A skeleton is a field list, so wanting the year is just asking for one
|
||||||
|
* more field; the locale still decides where it lands ("July 2026" vs "2026年7月").
|
||||||
|
*
|
||||||
|
* Dropping the year in the current year is the one bit of policy here: the title
|
||||||
|
* sits directly above a grid that already says which year it is, and the year's
|
||||||
|
* *absence* is itself the signal that you're in the current one — it appears the
|
||||||
|
* moment you page out of it, which is when it starts carrying information.
|
||||||
|
*
|
||||||
|
* [forceYear] overrides that for titles whose [date] does not tell the whole
|
||||||
|
* story — a week view's title names only the month its *first* day falls in, so
|
||||||
|
* a week straddling New Year must still show the year even though [date] is in
|
||||||
|
* [currentYear].
|
||||||
|
*/
|
||||||
|
fun formatCalendarTitle(
|
||||||
|
date: java.time.LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
currentYear: Int,
|
||||||
|
skeleton: String,
|
||||||
|
forceYear: Boolean = false,
|
||||||
|
): String {
|
||||||
|
val fields = if (date.year == currentYear && !forceYear) skeleton else skeleton + "y"
|
||||||
|
return localizedDateFormatter(locale, fields).format(date)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.ContentTransform
|
import androidx.compose.animation.ContentTransform
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -23,9 +24,13 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
* The M3 Expressive spatial spring used for the month/week/day slide: the
|
||||||
* spring-physics spec from the active motion scheme — snappy with a subtle
|
* *default* spring-physics spec from the active motion scheme, rather than a
|
||||||
* springy settle, rather than a fixed easing curve.
|
* fixed easing curve.
|
||||||
|
*
|
||||||
|
* Default rather than fast: `fastSpatialSpec` is tuned for small, incidental
|
||||||
|
* movement, and driving a whole calendar page with it made the settle read as a
|
||||||
|
* twitch instead of a glide.
|
||||||
*
|
*
|
||||||
* Read it in a composable scope (this helper) so it can be captured by the
|
* Read it in a composable scope (this helper) so it can be captured by the
|
||||||
* non-composable `AnimatedContent` transitionSpec lambda.
|
* non-composable `AnimatedContent` transitionSpec lambda.
|
||||||
@@ -33,27 +38,34 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The fast effects spec from the active motion scheme, for opacity (fade)
|
* The effects spec from the active motion scheme, for the opacity half of the
|
||||||
* transitions. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
|
||||||
* for use in non-composable transition lambdas and as the reduced-motion fallback.
|
* for use in non-composable transition lambdas, and reused on its own as the
|
||||||
|
* reduced-motion fallback.
|
||||||
|
*
|
||||||
|
* Opacity gets its own spec on purpose: M3 Expressive springs *position* and
|
||||||
|
* eases *opacity*, and a fade that bounced with the slide would shimmer.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
|
||||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
MaterialTheme.motionScheme.defaultEffectsSpec()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Horizontal slide for navigating between adjacent months/weeks/days.
|
* Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
|
||||||
|
* outgoing page slides and fades one way while the incoming one arrives from the
|
||||||
|
* other, position on a spring and opacity on an easing curve.
|
||||||
*
|
*
|
||||||
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
|
||||||
* (e.g. "today"); a jump reuses the forward direction.
|
* (e.g. "today"); a jump reuses the forward direction.
|
||||||
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
|
||||||
* @param fadeSpec effects spec for the reduced-motion fade, typically
|
* @param fadeSpec effects spec for the opacity half, and for the whole
|
||||||
|
* transition under reduced motion; typically
|
||||||
* [rememberCalendarFadeSpec].
|
* [rememberCalendarFadeSpec].
|
||||||
* @param reduceMotion when true, swap the directional slide for a plain cross-fade.
|
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
||||||
*/
|
*/
|
||||||
fun calendarSlideTransition(
|
fun calendarSlideTransition(
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
@@ -65,6 +77,26 @@ fun calendarSlideTransition(
|
|||||||
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
}
|
}
|
||||||
val dir = if (slideDir == 0) 1 else slideDir
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return ContentTransform(
|
||||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
targetContentEnter =
|
||||||
|
slideInHorizontally(spec) { w -> dir * w / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
|
||||||
|
initialContentExit =
|
||||||
|
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
|
||||||
|
// AnimatedContent clips to the animating container by default, which
|
||||||
|
// shears the pages against the viewport edge as they pass. There is no
|
||||||
|
// size change here to contain — both pages are the same grid.
|
||||||
|
sizeTransform = SizeTransform(clip = false),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a page travels, as a fraction of the container width.
|
||||||
|
*
|
||||||
|
* A full width was the obvious reading of "paging", but the two pages are
|
||||||
|
* stacked and both opaque, so a full-width slide showed one grid racing across
|
||||||
|
* another — the movement carried the whole transition and had a long way to go.
|
||||||
|
* Under M3's shared-axis pattern the offset only has to *hint* the direction
|
||||||
|
* while the cross-fade does the swapping, so a fifth of the width is plenty and
|
||||||
|
* leaves nothing skating past.
|
||||||
|
*/
|
||||||
|
private const val SLIDE_TRAVEL_DIVISOR = 5
|
||||||
|
|||||||
@@ -18,15 +18,14 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
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
|
||||||
* ringed and checked. Shared by the calendar editor and the event-colour
|
* ringed and checked. Shared by the calendar editor and the event-colour
|
||||||
* picker so both pick a colour the same way. Swatches render through
|
* picker so both pick a colour the same way. Swatches render through
|
||||||
* [pastelize] — the softened colour the app actually paints, not the raw hue.
|
* [eventFill] — the colour the app actually paints (softened, or raw when the
|
||||||
|
* softener is off, issue #36), not necessarily the stored hue.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -37,16 +36,18 @@ fun ColorSwatchRow(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
FlowRow(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
colors.forEach { argb ->
|
colors.forEach { argb ->
|
||||||
val isSelected = argb == selected
|
val isSelected = argb == selected
|
||||||
|
val fill = eventFill(argb, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(pastelize(argb, dark))
|
.background(fill)
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
@@ -60,7 +61,7 @@ fun ColorSwatchRow(
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Check,
|
Icons.Default.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.Black.copy(alpha = 0.7f),
|
tint = eventInk(fill, alpha = 0.7f),
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether calendar/event colours are softened toward theme-fitting pastels
|
||||||
|
* before display (issue #36). Provided app-wide from the "soften colours"
|
||||||
|
* setting; the default `true` keeps the historical look. When off, the raw
|
||||||
|
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
||||||
|
* and other calendar apps. Widgets live outside this composition and read the
|
||||||
|
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
||||||
|
*/
|
||||||
|
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
||||||
|
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
||||||
|
* pastelize also returns an opaque colour).
|
||||||
|
*/
|
||||||
|
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
|
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
||||||
|
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
||||||
|
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
||||||
|
* perceptually dark even after softening pins its HSV value, so black text on it
|
||||||
|
* reads poorly. The choice is objective, not a tuned threshold: white wins only
|
||||||
|
* when it out-contrasts black against the fill, which (by the WCAG contrast
|
||||||
|
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
|
||||||
|
* near-black text. [alpha] carries the caller's soft emphasis.
|
||||||
|
*/
|
||||||
|
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
|
||||||
|
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
||||||
|
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative-luminance crossover where white text starts to out-contrast black.
|
||||||
|
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
||||||
|
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
||||||
|
*/
|
||||||
|
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
||||||
@@ -9,6 +9,7 @@ import androidx.compose.material.icons.filled.Notifications
|
|||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.People
|
import androidx.compose.material.icons.filled.People
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -24,6 +25,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
|||||||
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
EventFormField.Description -> R.string.event_detail_description
|
||||||
|
EventFormField.Timezone -> R.string.event_detail_timezone
|
||||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
@@ -35,6 +37,7 @@ fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||||
EventFormField.Location -> Icons.Default.Place
|
EventFormField.Location -> Icons.Default.Place
|
||||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||||
|
EventFormField.Timezone -> Icons.Default.Public
|
||||||
EventFormField.Reminders -> Icons.Default.Notifications
|
EventFormField.Reminders -> Icons.Default.Notifications
|
||||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.core.os.ConfigurationCompat
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current display [Locale], read observably from [LocalConfiguration] so the UI
|
|
||||||
* recomposes after a locale change (lint: NonObservableLocale). Used for
|
|
||||||
* weekday/month name formatting.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun currentLocale(): Locale {
|
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
return remember(configuration) {
|
|
||||||
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SegmentedButton
|
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -29,16 +16,16 @@ 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.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 de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.CustomAmountEditor
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
@@ -195,61 +182,23 @@ private fun CustomReminderEditor(
|
|||||||
onConfirm: (Int) -> Unit,
|
onConfirm: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
Surface(
|
CustomAmountEditor(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
amountText = amountText,
|
||||||
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
onAmountChange = onAmountChange,
|
||||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
unitLabels = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||||
modifier = Modifier
|
selectedUnit = unit.ordinal,
|
||||||
.fillMaxWidth()
|
onUnitChange = { onUnitChange(ReminderUnit.entries[it]) },
|
||||||
.padding(horizontal = 16.dp),
|
preview = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
// Unit toggle first so it stays visible above the keyboard once the
|
|
||||||
// amount field (the bottom row) is focused and scrolled into view.
|
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
ReminderUnit.entries.forEachIndexed { index, entry ->
|
|
||||||
SegmentedButton(
|
|
||||||
selected = unit == entry,
|
|
||||||
onClick = { onUnitChange(entry) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
|
|
||||||
label = { Text(stringResource(reminderUnitLabel(entry))) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Amount, a live preview of the lead time it resolves to, and Set —
|
|
||||||
// all on one row, sitting just above the keyboard.
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
DialogAmountField(
|
|
||||||
value = amountText,
|
|
||||||
onValueChange = onAmountChange,
|
|
||||||
placeholder = "10",
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Text(
|
|
||||||
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
|
|
||||||
?: stringResource(R.string.reminder_custom_amount),
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
confirmEnabled = amount != null,
|
||||||
modifier = Modifier.weight(1f),
|
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
|
||||||
enabled = amount != null,
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.reminder_custom_set))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PickerDescription(text: String) {
|
internal fun PickerDescription(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -258,14 +207,6 @@ private fun PickerDescription(text: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SelectedCheck() {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||||
@@ -364,41 +305,136 @@ private fun CustomDaysEditor(
|
|||||||
) {
|
) {
|
||||||
val days = amountText.toIntOrNull()
|
val days = amountText.toIntOrNull()
|
||||||
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||||
Surface(
|
CustomAmountEditor(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
amountText = amountText,
|
||||||
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
onAmountChange = onAmountChange,
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
DialogAmountField(
|
|
||||||
value = amountText,
|
|
||||||
onValueChange = onAmountChange,
|
|
||||||
placeholder = "30",
|
placeholder = "30",
|
||||||
)
|
preview = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Text(
|
|
||||||
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
|
||||||
?: stringResource(R.string.agenda_range_custom_hint),
|
?: stringResource(R.string.agenda_range_custom_hint),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
confirmEnabled = days != null,
|
||||||
modifier = Modifier.weight(1f),
|
onConfirm = { days?.let(onConfirm) },
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(16.dp))
|
}
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { days?.let(onConfirm) },
|
/**
|
||||||
enabled = days != null,
|
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
||||||
|
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
||||||
|
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
||||||
|
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
||||||
|
* a preset or confirming a custom value applies via [onSelect] and closes.
|
||||||
|
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
||||||
|
* is reused for both the rows and the custom preview.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SnoozeDurationPicker(
|
||||||
|
title: String,
|
||||||
|
presets: List<Int>,
|
||||||
|
selected: Int,
|
||||||
|
label: @Composable (Int) -> String,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val customSelected = selected !in presets
|
||||||
|
val rowCount = presets.size + 1 // + the custom row
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable {
|
||||||
|
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
||||||
|
}
|
||||||
|
var unit by rememberSaveable {
|
||||||
|
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||||
|
presets.forEachIndexed { index, minute ->
|
||||||
|
val isSelected = minute == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = label(minute),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(minute)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The Custom row connects downward into the editor card when expanded, so
|
||||||
|
// the two read as one grouped container (the shared custom-expand pattern).
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
trailing = if (customSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = customExpanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.reminder_custom_set))
|
CustomSnoozeEditor(
|
||||||
}
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
label = label,
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(minutes)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whole hours if the delay divides evenly, else minutes. */
|
||||||
|
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
||||||
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||||
|
|
||||||
|
private fun snoozeCustomAmount(minutes: Int): Int =
|
||||||
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
||||||
|
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||||
|
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||||
|
* amount. [onConfirm] receives the final delay in minutes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CustomSnoozeEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
unit: ReminderUnit,
|
||||||
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
|
label: @Composable (Int) -> String,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
||||||
|
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
CustomAmountEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = onAmountChange,
|
||||||
|
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||||
|
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
||||||
|
onUnitChange = { onUnitChange(units[it]) },
|
||||||
|
preview = amount?.let { label(it * unit.minutesFactor) }
|
||||||
|
?: stringResource(R.string.reminder_custom_amount),
|
||||||
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
|
confirmEnabled = amount != null,
|
||||||
|
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||||
@Composable
|
@Composable
|
||||||
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import androidx.compose.ui.text.buildAnnotatedString
|
|||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
@@ -111,16 +115,36 @@ private fun rruleDayName(token: String, locale: Locale): String? {
|
|||||||
|
|
||||||
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
/** Parse an RRULE UNTIL value ("20261231" or "20261231T235959Z") to a localized date. */
|
||||||
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
private fun parseUntilDate(raw: String, locale: Locale): String? {
|
||||||
val digits = raw.takeWhile { it.isDigit() }
|
val date = untilLocalDate(raw) ?: return null
|
||||||
if (digits.length < 8) return null
|
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
||||||
return try {
|
}
|
||||||
val date = java.time.LocalDate.of(
|
|
||||||
|
private val UNTIL_UTC_FORMAT: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendar day an RRULE UNTIL value denotes, *in the device's zone*.
|
||||||
|
*
|
||||||
|
* The UTC form ("20261231T225959Z") must be converted back before its date is
|
||||||
|
* read: `SimpleRecurrence.toRRule` writes the end of the chosen **local** day
|
||||||
|
* expressed in UTC, so for zones behind UTC that instant already falls on the
|
||||||
|
* following UTC date. Taking the leading digits straight off would then show
|
||||||
|
* the day after the one the user picked. Date-only and floating forms are
|
||||||
|
* already local and pass through unchanged.
|
||||||
|
*/
|
||||||
|
internal fun untilLocalDate(raw: String, zone: ZoneId = ZoneId.systemDefault()): LocalDate? {
|
||||||
|
val value = raw.trim()
|
||||||
|
return runCatching {
|
||||||
|
LocalDateTime.parse(value, UNTIL_UTC_FORMAT)
|
||||||
|
.atOffset(ZoneOffset.UTC)
|
||||||
|
.atZoneSameInstant(zone)
|
||||||
|
.toLocalDate()
|
||||||
|
}.recoverCatching {
|
||||||
|
val digits = value.takeWhile { it.isDigit() }
|
||||||
|
LocalDate.of(
|
||||||
digits.substring(0, 4).toInt(),
|
digits.substring(0, 4).toInt(),
|
||||||
digits.substring(4, 6).toInt(),
|
digits.substring(4, 6).toInt(),
|
||||||
digits.substring(6, 8).toInt(),
|
digits.substring(6, 8).toInt(),
|
||||||
)
|
)
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(date)
|
}.getOrNull()
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Public
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.filterTimeZones
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptions
|
||||||
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen zone picker: a query field over every zone the JVM knows, with the
|
||||||
|
* device zone and recently-picked ones pinned on top so the common case needs no
|
||||||
|
* typing. [selected] is the pinned zone id, or null when the event follows the
|
||||||
|
* device — picking the device row reports null back, which is what keeps an
|
||||||
|
* unpinned event unpinned rather than freezing it to today's zone.
|
||||||
|
*
|
||||||
|
* Unlike the kit's [de.jeanlucmakiola.floret.components.OptionPicker], this
|
||||||
|
* drives its own [LazyColumn] (hence `scrollable = false`): ~600 options is far
|
||||||
|
* past what an eagerly composed column can carry.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TimeZonePickerDialog(
|
||||||
|
selected: String?,
|
||||||
|
deviceZoneId: String,
|
||||||
|
recents: List<String>,
|
||||||
|
onSelect: (String?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
|
val locale = currentLocale()
|
||||||
|
// ~600 zones, each resolving a localized name and up to two ICU short names:
|
||||||
|
// far too much to run inside composition, so build it on a worker and let the
|
||||||
|
// list fill in a frame later. Filtering the built catalogue is cheap (its
|
||||||
|
// search keys are pre-normalized), so that stays here.
|
||||||
|
val allZones by produceState(initialValue = emptyList<TimeZoneOption>(), locale) {
|
||||||
|
value = withContext(Dispatchers.Default) {
|
||||||
|
timeZoneOptions(locale, regionOf = ::icuTimeZoneRegion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val matches = remember(allZones, query) { filterTimeZones(allZones, query) }
|
||||||
|
val recentZones = remember(allZones, recents) {
|
||||||
|
recents.mapNotNull { id -> allZones.firstOrNull { it.id == id } }
|
||||||
|
}
|
||||||
|
// Resolved on its own rather than looked up in the catalogue: it's one zone,
|
||||||
|
// so it costs nothing, and the device row can then render complete on the
|
||||||
|
// first frame instead of showing a bare id until the catalogue lands.
|
||||||
|
val deviceSummary = remember(deviceZoneId, locale) {
|
||||||
|
timeZoneOptionOf(deviceZoneId, locale, regionOf = ::icuTimeZoneRegion)
|
||||||
|
?.let { zoneDescriptor(it) }
|
||||||
|
?: deviceZoneId
|
||||||
|
}
|
||||||
|
val searching = query.isNotBlank()
|
||||||
|
|
||||||
|
fun choose(zoneId: String?) {
|
||||||
|
onSelect(zoneId)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.event_detail_timezone),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
scrollable = false,
|
||||||
|
) {
|
||||||
|
SearchField(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = { query = it },
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)) {
|
||||||
|
if (!searching) {
|
||||||
|
// The device row is the "unpinned" choice, so it reads as a mode
|
||||||
|
// rather than as a zone among zones — it stays out of the big
|
||||||
|
// list and never carries an offset.
|
||||||
|
item(key = "device") {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.event_edit_timezone_device),
|
||||||
|
summary = deviceSummary,
|
||||||
|
position = Position.Alone,
|
||||||
|
selected = selected == null,
|
||||||
|
leading = { Icon(Icons.Default.Public, contentDescription = null) },
|
||||||
|
trailing = if (selected == null) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { choose(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (recentZones.isNotEmpty()) {
|
||||||
|
item(key = "recent_header") {
|
||||||
|
SectionHeader(stringResource(R.string.event_edit_timezone_recent))
|
||||||
|
}
|
||||||
|
itemsIndexed(
|
||||||
|
items = recentZones,
|
||||||
|
key = { _, zone -> "recent_${zone.id}" },
|
||||||
|
) { index, zone ->
|
||||||
|
ZoneRow(
|
||||||
|
zone = zone,
|
||||||
|
position = positionOf(index, recentZones.size),
|
||||||
|
selected = zone.id == selected,
|
||||||
|
onClick = { choose(zone.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "all_header") {
|
||||||
|
SectionHeader(stringResource(R.string.event_edit_timezone_all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allZones.isNotEmpty() gates this: until the catalogue lands there is
|
||||||
|
// simply nothing to match yet, and claiming "no time zone matches"
|
||||||
|
// for that frame would be wrong.
|
||||||
|
if (searching && matches.isEmpty() && allZones.isNotEmpty()) {
|
||||||
|
item(key = "empty") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_edit_timezone_none, query),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp, vertical = 48.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsIndexed(
|
||||||
|
items = matches,
|
||||||
|
key = { _, zone -> "zone_${zone.id}" },
|
||||||
|
) { index, zone ->
|
||||||
|
ZoneRow(
|
||||||
|
zone = zone,
|
||||||
|
position = positionOf(index, matches.size),
|
||||||
|
selected = zone.id == selected,
|
||||||
|
onClick = { choose(zone.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The query box: the family's borderless [InlineTextField] over a tonal surface,
|
||||||
|
* so it reads like the rest of the app's inputs rather than a boxed Material
|
||||||
|
* field. The clear button rides inside the surface (unlike the search screen,
|
||||||
|
* which has a top bar to put it in) because the picker's bar is the title.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SearchField(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
// surfaceContainerHighest — the picker sits on surfaceContainerHigh, so
|
||||||
|
// anything lower vanishes into it.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
InlineTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = stringResource(R.string.event_edit_timezone_search),
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
onImeAction = { keyboard?.hide() },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_clear),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small primary-coloured group label, matching the settings screens. */
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ZoneRow(
|
||||||
|
zone: TimeZoneOption,
|
||||||
|
position: Position,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = zone.label,
|
||||||
|
summary = zoneDescriptor(zone),
|
||||||
|
position = position,
|
||||||
|
selected = selected,
|
||||||
|
trailing = if (selected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.icu.util.TimeZone as IcuTimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ISO 3166 region an IANA zone belongs to ("America/New_York" -> "US"), or
|
||||||
|
* null when ICU has none or maps it to the multi-country "001" ("Etc/UTC").
|
||||||
|
*
|
||||||
|
* This is the `android.icu`-backed bridge the pure-JVM
|
||||||
|
* [de.jeanlucmakiola.calendula.domain.timeZoneOptions] takes as `regionOf`, so
|
||||||
|
* it can ask ICU for a zone's abbreviation in that zone's own region — the only
|
||||||
|
* region for which ICU will surface it (see `resolveAbbreviation`). Lives here,
|
||||||
|
* not in `domain/`, precisely because it needs an Android import.
|
||||||
|
*/
|
||||||
|
fun icuTimeZoneRegion(id: String): String? =
|
||||||
|
runCatching { IcuTimeZone.getRegion(id) }.getOrNull()?.takeIf { it.length == 2 }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Today
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top-bar "jump to today" icon button, shared by every calendar view's app
|
||||||
|
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the
|
||||||
|
* today control into the toolbar; renders nothing when [show] is false, so it
|
||||||
|
* drops straight into an app bar's `actions` slot without a wrapping condition.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TodayAction(show: Boolean, onToday: () -> Unit) {
|
||||||
|
if (!show) return
|
||||||
|
IconButton(onClick = onToday) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Today,
|
||||||
|
contentDescription = stringResource(R.string.today_jump_action),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
|
|||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -43,7 +42,6 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -68,8 +66,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
@@ -77,11 +77,14 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
@@ -91,12 +94,18 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
private val HOUR_HEIGHT = 56.dp
|
||||||
private val GUTTER_WIDTH = 48.dp
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||||
|
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||||
|
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||||
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
private val MIN_EVENT_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
@@ -119,6 +128,7 @@ fun DayScreen(
|
|||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -151,6 +161,13 @@ fun DayScreen(
|
|||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drives whether the title carries the year. Falls back to the clock only
|
||||||
|
// while the first load is in flight, when there is no state to read today from.
|
||||||
|
val currentYear = when (val s = state) {
|
||||||
|
is DayUiState.Success -> s.today.year
|
||||||
|
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||||
|
}
|
||||||
|
|
||||||
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
@@ -199,16 +216,19 @@ fun DayScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnToday,
|
todayVisible = !isOnToday && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.day_today_action),
|
todayText = stringResource(R.string.day_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = { onCreateEvent(date, null) },
|
onCreate = { onCreateEvent(date, null) },
|
||||||
@@ -245,8 +265,6 @@ private fun DayContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val threshold = with(density) { 24.dp.toPx() }
|
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -275,20 +293,7 @@ private fun DayContent(
|
|||||||
// Whole-page horizontal swipe, one level above the timeline's vertical
|
// Whole-page horizontal swipe, one level above the timeline's vertical
|
||||||
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
// scroll: a horizontal drag crosses this detector's slop, while a vertical
|
||||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { dragAccum = 0f },
|
|
||||||
onDragEnd = {
|
|
||||||
when {
|
|
||||||
dragAccum < -threshold -> onSwipeNext()
|
|
||||||
dragAccum > threshold -> onSwipePrev()
|
|
||||||
}
|
|
||||||
dragAccum = 0f
|
|
||||||
},
|
|
||||||
onDragCancel = { dragAccum = 0f },
|
|
||||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
@@ -354,16 +359,20 @@ private fun DaySuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DayTopBar(
|
private fun DayTopBar(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = formatDayTitle(date),
|
text = formatDayTitle(date, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -376,6 +385,7 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -448,9 +458,11 @@ private fun AllDayBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
@@ -461,7 +473,7 @@ private fun AllDayBar(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.8f),
|
color = eventInk(fill),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,10 +496,12 @@ private fun Timeline(
|
|||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position.
|
// soft corners are permanent at any scroll position.
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
// Hour gutter (scrolls in sync with the day column)
|
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||||
|
// labels centre on the top bar hamburger, matching the week view.
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(GUTTER_WIDTH)
|
.width(GUTTER_WIDTH)
|
||||||
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
@@ -610,9 +624,11 @@ private fun EventBlock(
|
|||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val showTime = block.endMin - block.startMin >= 45
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
@@ -623,7 +639,7 @@ private fun EventBlock(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = if (showTime) 1 else 2,
|
maxLines = if (showTime) 1 else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
)
|
)
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
Text(
|
||||||
@@ -631,7 +647,7 @@ private fun EventBlock(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.6f),
|
color = eventInk(fill, alpha = 0.6f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -657,10 +673,10 @@ private fun DayLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate): String {
|
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
||||||
val locale = Locale.getDefault()
|
formatCalendarTitle(
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
locale = locale,
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
currentYear = currentYear,
|
||||||
return "$weekday, ${date.day}. $monthName ${date.year}"
|
skeleton = "EEEdMMM",
|
||||||
}
|
)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
@@ -87,25 +88,31 @@ import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
|||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
import de.jeanlucmakiola.calendula.domain.AttendeeType
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.TimeZoneOption
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.identity.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.icuTimeZoneRegion
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
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
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -116,7 +123,10 @@ import kotlin.time.Instant
|
|||||||
* top-bar arrow both return to the calendar. Events in writable calendars can
|
* top-bar arrow both return to the calendar. Events in writable calendars can
|
||||||
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
* be deleted (v1.1) and edited (v1.3) from here; [onEdit] opens the shared
|
||||||
* event form for this occurrence — for recurring events the form asks how
|
* event form for this occurrence — for recurring events the form asks how
|
||||||
* far the change reaches when saving.
|
* far the change reaches when saving. [onDuplicate] opens the same form
|
||||||
|
* seeded with a copy of this event as a new, unsaved event (#52) — offered
|
||||||
|
* for any event, including read-only ones, since the copy lands in an
|
||||||
|
* editable calendar.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -126,6 +136,7 @@ fun EventDetailScreen(
|
|||||||
endMillis: Long,
|
endMillis: Long,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
|
onDuplicate: (EventForm) -> Unit,
|
||||||
viewModel: EventDetailViewModel = hiltViewModel(),
|
viewModel: EventDetailViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(eventId, beginMillis, endMillis) {
|
LaunchedEffect(eventId, beginMillis, endMillis) {
|
||||||
@@ -160,15 +171,14 @@ fun EventDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
// v1.0 installs only hold READ_CALENDAR; the first write asks for the
|
||||||
// upgrade in place. Granting continues straight into the tapped action.
|
// upgrade in place. Granting continues straight into the tapped action —
|
||||||
var pendingEdit by remember { mutableStateOf(false) }
|
// edit, delete, or duplicate — held here until the result comes back.
|
||||||
|
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
val writePermissionLauncher = rememberLauncherForActivityResult(
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
if (granted) {
|
if (granted) pendingWrite?.invoke()
|
||||||
if (pendingEdit) onEdit() else showDeleteDialog = true
|
pendingWrite = null
|
||||||
}
|
|
||||||
pendingEdit = false
|
|
||||||
}
|
}
|
||||||
val hasWritePermission = {
|
val hasWritePermission = {
|
||||||
ContextCompat.checkSelfPermission(
|
ContextCompat.checkSelfPermission(
|
||||||
@@ -176,21 +186,20 @@ fun EventDetailScreen(
|
|||||||
Manifest.permission.WRITE_CALENDAR,
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
val onDeleteClick = {
|
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||||
if (hasWritePermission()) {
|
if (hasWritePermission()) {
|
||||||
showDeleteDialog = true
|
action()
|
||||||
} else {
|
} else {
|
||||||
pendingEdit = false
|
pendingWrite = action
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onEditClick = {
|
val onDeleteClick = { requireWrite { showDeleteDialog = true } }
|
||||||
if (hasWritePermission()) {
|
val onEditClick = { requireWrite(onEdit) }
|
||||||
onEdit()
|
// Duplicate reads the loaded detail into a create form and hands it up; the
|
||||||
} else {
|
// create still needs WRITE_CALENDAR even when the source is read-only.
|
||||||
pendingEdit = true
|
val onDuplicateClick = {
|
||||||
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
requireWrite { viewModel.duplicateForm()?.let(onDuplicate) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
val deleteFailedMessage = stringResource(R.string.event_delete_failed)
|
||||||
@@ -229,7 +238,8 @@ fun EventDetailScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
val s = state
|
val s = state
|
||||||
// Share works for any loaded event — it only reads the event.
|
// Share and duplicate work for any loaded event — both only
|
||||||
|
// read it; the duplicate is created into a writable calendar.
|
||||||
if (s is EventDetailUiState.Success) {
|
if (s is EventDetailUiState.Success) {
|
||||||
IconButton(onClick = onShareClick) {
|
IconButton(onClick = onShareClick) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -237,6 +247,15 @@ fun EventDetailScreen(
|
|||||||
contentDescription = stringResource(R.string.event_detail_share),
|
contentDescription = stringResource(R.string.event_detail_share),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onDuplicateClick,
|
||||||
|
enabled = deleteState != DeleteUiState.Deleting,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = stringResource(R.string.event_detail_duplicate),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Edit/delete need a writable calendar — WebCal subscriptions,
|
// Edit/delete need a writable calendar — WebCal subscriptions,
|
||||||
// birthday calendars etc. are read-only at the provider level.
|
// birthday calendars etc. are read-only at the provider level.
|
||||||
@@ -367,7 +386,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
val instance = detail.instance
|
val instance = detail.instance
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val locale = currentDetailLocale()
|
val locale = currentDetailLocale()
|
||||||
val accent = pastelize(instance.color, dark)
|
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -438,14 +457,44 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Time zone — only when the event is timed and pinned to a zone other
|
// Time zone — only when the event is timed and pinned to a zone other
|
||||||
// than the device's, so cross-zone events read unambiguously.
|
// than the device's, so cross-zone events read unambiguously. It answers
|
||||||
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
// "when was it set", which the zone's name alone never did: an 8 AM New
|
||||||
|
// York call showing as 2 PM here should still say 8 AM somewhere.
|
||||||
|
//
|
||||||
|
// Same hierarchy as the When card above — the label carries the card,
|
||||||
|
// the time sits small beneath it. The local time is the one the reader
|
||||||
|
// acts on, so the original must stay quieter than it, not compete.
|
||||||
|
val foreignZone = remember(detail.eventTimezone, instance.isAllDay, instance.start, locale) {
|
||||||
|
foreignTimeZone(detail.eventTimezone, instance.isAllDay, locale, instance.start)
|
||||||
|
}
|
||||||
|
foreignZone?.let { zoneOption ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.Public,
|
icon = Icons.Default.Public,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||||
) {
|
) {
|
||||||
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
// The id is the primary label ("Europe/Berlin"); the long
|
||||||
|
// localized name spelled next to it made the field too wide.
|
||||||
|
Text(text = zoneOption.label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
// Beneath, small: the abbreviation plus the event's own-zone time,
|
||||||
|
// e.g. "CET · 8:00 AM – 9:00 AM". formatWhen puts the range in the
|
||||||
|
// secondary half only for a same-day event; one spanning midnight
|
||||||
|
// — which a cross-zone event easily does — carries the whole span
|
||||||
|
// in the primary instead, so fall back to it. If the zone can't be
|
||||||
|
// read back at all, drop to just the abbreviation + offset.
|
||||||
|
val originalTime = runCatching { TimeZone.of(zoneOption.id) }.getOrNull()
|
||||||
|
?.let { formatWhen(instance, it, locale) }
|
||||||
|
?.let { (primary, secondary) -> secondary ?: primary }
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = if (originalTime != null) {
|
||||||
|
"${zoneOption.shortName} · $originalTime"
|
||||||
|
} else {
|
||||||
|
zoneDescriptor(zoneOption)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,21 +786,30 @@ private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
|||||||
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
private fun reminderLeadText(reminder: Reminder): String = reminderLeadTimeLabel(reminder.minutes)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
* [tz] resolved as a [TimeZoneOption], but only when the event is timed and
|
||||||
* but only when the event is timed and pinned to a zone different from the
|
* pinned to a zone different from the device's — the cases where showing it
|
||||||
* device's. Returns null when there's nothing worth showing.
|
* removes ambiguity. Null otherwise (all-day, device-zone, blank, or an id the
|
||||||
|
* tz database doesn't know).
|
||||||
|
*
|
||||||
|
* Resolved *at [at]*, the event's own start, not at "now": the abbreviation and
|
||||||
|
* offset both move with DST, and the card prints them next to the event's time.
|
||||||
|
* Resolving at now would label a July event "CET · 10:00 AM" when read in
|
||||||
|
* January — an abbreviation that contradicts the time beside it.
|
||||||
*/
|
*/
|
||||||
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
|
private fun foreignTimeZone(
|
||||||
|
tz: String?,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
at: Instant,
|
||||||
|
): TimeZoneOption? {
|
||||||
if (isAllDay || tz.isNullOrBlank()) return null
|
if (isAllDay || tz.isNullOrBlank()) return null
|
||||||
val deviceZone = ZoneId.systemDefault().id
|
if (tz == ZoneId.systemDefault().id) return null
|
||||||
if (tz == deviceZone) return null
|
return timeZoneOptionOf(
|
||||||
return try {
|
tz,
|
||||||
val zone = ZoneId.of(tz)
|
locale,
|
||||||
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
at = at.toJavaInstant(),
|
||||||
if (name == tz) tz else "$name ($tz)"
|
regionOf = ::icuTimeZoneRegion,
|
||||||
} catch (e: Exception) {
|
)
|
||||||
tz
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.toShareIcsEvent
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -138,6 +141,31 @@ 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
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ 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
|
||||||
@@ -97,7 +98,6 @@ 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.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.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -110,6 +110,9 @@ import de.jeanlucmakiola.calendula.domain.EventAttendee
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
||||||
|
import de.jeanlucmakiola.calendula.domain.timesIn
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
@@ -125,27 +128,33 @@ 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.TimeZonePickerDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.groupedShape as floretGroupedShape
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
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.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.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.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
|
||||||
@@ -155,13 +164,14 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.isoDayNumber
|
import kotlinx.datetime.isoDayNumber
|
||||||
import kotlinx.datetime.toJavaDayOfWeek
|
import kotlinx.datetime.toJavaDayOfWeek
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toKotlinDayOfWeek
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toJavaLocalTime
|
import kotlinx.datetime.toJavaLocalTime
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.time.temporal.WeekFields
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
@@ -182,12 +192,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],
|
||||||
@@ -202,6 +214,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 /
|
||||||
@@ -332,6 +345,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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -440,6 +503,7 @@ private fun EventEditContent(
|
|||||||
) {
|
) {
|
||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val firstDayOfWeek by viewModel.firstDayOfWeek.collectAsStateWithLifecycle()
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
// The title, date and recurrence are managed by the special-dates sync, so
|
// The title, date and recurrence are managed by the special-dates sync, so
|
||||||
// they're locked here; everything else (reminders, location, notes) is the
|
// they're locked here; everything else (reminders, location, notes) is the
|
||||||
@@ -451,6 +515,7 @@ private fun EventEditContent(
|
|||||||
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
var showRecurrencePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
var showVisibilityPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
var showColorPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showTimezonePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
// Pick a guest from contacts: a one-shot system picker returning the chosen
|
||||||
@@ -487,7 +552,8 @@ private fun EventEditContent(
|
|||||||
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId }
|
||||||
// The accent ties the form to the detail screen's design language: the
|
// The accent ties the form to the detail screen's design language: the
|
||||||
// bar under the title takes the target calendar's colour.
|
// bar under the title takes the target calendar's colour.
|
||||||
val accent = selectedCalendar?.let { pastelize(it.color, dark) }
|
val soften = LocalSoftenColors.current
|
||||||
|
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
|
||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
@@ -614,19 +680,42 @@ private fun EventEditContent(
|
|||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// The rows above edit a pinned event in *its own* zone, which is the
|
||||||
|
// time it was set at — but that says nothing about when it lands for
|
||||||
|
// whoever is reading it. Spell the local equivalent out rather than
|
||||||
|
// leaving the user to do the offset arithmetic. Absent (null) unless
|
||||||
|
// the event is pinned somewhere other than here.
|
||||||
|
// Keyed rather than recomputed: this sits in the same Column as the
|
||||||
|
// title field, so it would otherwise re-parse the zone on every
|
||||||
|
// keystroke.
|
||||||
|
val localTimes = remember(form.timezone, form.start, form.end, form.isAllDay) {
|
||||||
|
form.timesIn(TimeZone.currentSystemDefault())
|
||||||
|
}
|
||||||
|
localTimes?.let { (localStart, localEnd) ->
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.event_edit_timezone_local_time,
|
||||||
|
formatTimeRange(localStart, localEnd, locale),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -649,6 +738,54 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
// Optional sections: which ones render by default is a setting; the
|
// Optional sections: which ones render by default is a setting; the
|
||||||
// rest unfold behind the "more fields" button below.
|
// rest unfold behind the "more fields" button below.
|
||||||
|
OptionalFormSection(visible = EventFormField.Timezone in state.visibleFields) {
|
||||||
|
Spacer(Modifier.height(gap))
|
||||||
|
EditCard(
|
||||||
|
icon = Icons.Default.Public,
|
||||||
|
iconContentDescription = null,
|
||||||
|
onClick = { showTimezonePicker = true },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
// Resolved at the event's own start, not at "now": the
|
||||||
|
// abbreviation and offset shown ("CEST · GMT+02:00") must be
|
||||||
|
// the ones that apply when the event actually happens.
|
||||||
|
val pinned = remember(form.timezone, form.start, locale) {
|
||||||
|
form.timezone
|
||||||
|
?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() }
|
||||||
|
?.let { (id, zone) ->
|
||||||
|
timeZoneOptionOf(
|
||||||
|
id,
|
||||||
|
locale,
|
||||||
|
at = form.start.toInstant(zone).toJavaInstant(),
|
||||||
|
regionOf = ::icuTimeZoneRegion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = pinned?.label
|
||||||
|
?: stringResource(R.string.event_edit_timezone_device),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pinned?.let { zoneDescriptor(it) }
|
||||||
|
?: stringResource(R.string.event_edit_timezone_device_summary),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
|
OptionalFormSection(visible = EventFormField.Location in state.visibleFields) {
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
EditCard(
|
EditCard(
|
||||||
@@ -838,7 +975,7 @@ private fun EventEditContent(
|
|||||||
icon = Icons.Default.Palette,
|
icon = Icons.Default.Palette,
|
||||||
iconContentDescription = stringResource(R.string.event_edit_color),
|
iconContentDescription = stringResource(R.string.event_edit_color),
|
||||||
iconTint = if (colorSupported && swatch != null) {
|
iconTint = if (colorSupported && swatch != null) {
|
||||||
pastelize(swatch, dark)
|
eventFill(swatch, dark, soften)
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
},
|
},
|
||||||
@@ -1005,6 +1142,7 @@ private fun EventEditContent(
|
|||||||
RecurrencePickerDialog(
|
RecurrencePickerDialog(
|
||||||
current = form.rrule,
|
current = form.rrule,
|
||||||
startDay = form.start.date.dayOfWeek,
|
startDay = form.start.date.dayOfWeek,
|
||||||
|
firstDayOfWeek = firstDayOfWeek,
|
||||||
onSelect = { rrule ->
|
onSelect = { rrule ->
|
||||||
viewModel.setRecurrence(rrule)
|
viewModel.setRecurrence(rrule)
|
||||||
showRecurrencePicker = false
|
showRecurrencePicker = false
|
||||||
@@ -1024,6 +1162,16 @@ private fun EventEditContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showTimezonePicker) {
|
||||||
|
TimeZonePickerDialog(
|
||||||
|
selected = form.timezone,
|
||||||
|
deviceZoneId = ZoneId.systemDefault().id,
|
||||||
|
recents = state.recentTimeZones,
|
||||||
|
onSelect = viewModel::setTimezone,
|
||||||
|
onDismiss = { showTimezonePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showColorPicker) {
|
if (showColorPicker) {
|
||||||
ColorPickerDialog(
|
ColorPickerDialog(
|
||||||
palette = state.colorPalette,
|
palette = state.colorPalette,
|
||||||
@@ -1166,6 +1314,7 @@ private enum class RecurrenceEndMode { Never, Until, Count }
|
|||||||
private fun RecurrencePickerDialog(
|
private fun RecurrencePickerDialog(
|
||||||
current: String?,
|
current: String?,
|
||||||
startDay: DayOfWeek,
|
startDay: DayOfWeek,
|
||||||
|
firstDayOfWeek: DayOfWeek,
|
||||||
onSelect: (String?) -> Unit,
|
onSelect: (String?) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -1200,8 +1349,21 @@ private fun RecurrencePickerDialog(
|
|||||||
|
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
val untilDate = untilIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
|
||||||
val interval = intervalText.toIntOrNull()?.takeIf { it in 1..999 }
|
// remember() must not sit behind the ?. — a slot that appears and disappears
|
||||||
val count = countText.toIntOrNull()?.takeIf { it in 1..999 }
|
// as a date is picked breaks Compose's positional memoisation. Format
|
||||||
|
// unconditionally and let the null fall through to a null summary.
|
||||||
|
val untilSummary = remember(untilDate, locale) {
|
||||||
|
untilDate?.let {
|
||||||
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||||
|
.withLocale(locale).format(it.toJavaLocalDate())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A blank amount field shows its placeholder ("1" / "10") greyed out, so read
|
||||||
|
// blank as that default rather than as an error — otherwise simply clearing
|
||||||
|
// the field silently greys out OK. Only a real out-of-range value (0) is
|
||||||
|
// invalid, and that's the one case the read-out below reports.
|
||||||
|
val interval = if (intervalText.isBlank()) 1 else intervalText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
|
val count = if (countText.isBlank()) 10 else countText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||||
val customEnd: RecurrenceEnd? = when (endMode) {
|
val customEnd: RecurrenceEnd? = when (endMode) {
|
||||||
RecurrenceEndMode.Never -> RecurrenceEnd.Never
|
RecurrenceEndMode.Never -> RecurrenceEnd.Never
|
||||||
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
RecurrenceEndMode.Until -> untilDate?.let { RecurrenceEnd.Until(it) }
|
||||||
@@ -1237,6 +1399,11 @@ private fun RecurrencePickerDialog(
|
|||||||
title = stringResource(R.string.event_edit_recurrence_none),
|
title = stringResource(R.string.event_edit_recurrence_none),
|
||||||
position = positionOf(0, rowCount),
|
position = positionOf(0, rowCount),
|
||||||
selected = current == null,
|
selected = current == null,
|
||||||
|
trailing = if (current == null) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { onSelect(null) },
|
onClick = { onSelect(null) },
|
||||||
)
|
)
|
||||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
@@ -1244,6 +1411,11 @@ private fun RecurrencePickerDialog(
|
|||||||
title = stringResource(recurrencePresetLabel(entry)),
|
title = stringResource(recurrencePresetLabel(entry)),
|
||||||
position = positionOf(index + 1, rowCount),
|
position = positionOf(index + 1, rowCount),
|
||||||
selected = isPlainPreset && parsed?.freq == entry,
|
selected = isPlainPreset && parsed?.freq == entry,
|
||||||
|
trailing = if (isPlainPreset && parsed?.freq == entry) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1251,12 +1423,54 @@ private fun RecurrencePickerDialog(
|
|||||||
title = stringResource(R.string.event_edit_recurrence_custom),
|
title = stringResource(R.string.event_edit_recurrence_custom),
|
||||||
position = positionOf(rowCount - 1, rowCount),
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
selected = current != null && !isPlainPreset,
|
selected = current != null && !isPlainPreset,
|
||||||
|
trailing = if (current != null && !isPlainPreset) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { customMode = true },
|
onClick = { customMode = true },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
// Section gaps live on each block (not a parent spacedBy) so the
|
||||||
|
// weekday card's gap collapses *with* it under AnimatedVisibility —
|
||||||
|
// a parent arrangement would leave a residual gap that snaps shut.
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// A live, human-readable read-out of the rule being built, so the
|
||||||
|
// effect of the controls below is never a guess. It renders
|
||||||
|
// *[customResult] itself* — the exact string OK would save — so it
|
||||||
|
// can never describe a different rule than the one that lands. When
|
||||||
|
// the form can't produce a rule, it says why instead of falling
|
||||||
|
// back to defaults and quietly disagreeing with the controls.
|
||||||
|
// minLines reserves the second line up front: without it the whole
|
||||||
|
// stack below shifts a line as the phrase grows with each weekday.
|
||||||
|
Text(
|
||||||
|
text = customResult?.let { recurrenceText(it, locale) }
|
||||||
|
?: AnnotatedString(
|
||||||
|
stringResource(R.string.event_edit_recurrence_incomplete),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = if (customResult != null) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
},
|
||||||
|
minLines = 2,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
// How often: an interval amount plus a frequency segmented row —
|
||||||
|
// all four units on show, no unit hidden behind a dropdown.
|
||||||
|
GroupedSurface(
|
||||||
|
position = Position.Alone,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 16.dp)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
modifier = Modifier.padding(horizontal = 24.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
@@ -1269,44 +1483,86 @@ private fun RecurrencePickerDialog(
|
|||||||
onValueChange = { intervalText = it },
|
onValueChange = { intervalText = it },
|
||||||
placeholder = "1",
|
placeholder = "1",
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(12.dp))
|
}
|
||||||
DialogUnitDropdown(
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
label = stringResource(recurrenceUnitLabel(freq)),
|
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||||
entries = RecurrenceFreq.entries.map {
|
SegmentedButton(
|
||||||
stringResource(recurrenceUnitLabel(it))
|
selected = freq == entry,
|
||||||
|
onClick = { freq = entry },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(
|
||||||
|
index, RecurrenceFreq.entries.size,
|
||||||
|
),
|
||||||
|
// Drop the default check-icon slot: four segments
|
||||||
|
// share one row, and its reserved width is what
|
||||||
|
// pushes longer labels ("Monate", "Wochen") into
|
||||||
|
// an ellipsis on a compact screen. Selection is
|
||||||
|
// already carried by the segment's fill.
|
||||||
|
icon = {},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(recurrenceUnitLabel(entry)),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onPick = { freq = RecurrenceFreq.entries[it] },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (freq == RecurrenceFreq.Weekly) {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekday picks — only meaningful on a weekly rule. The 16dp gap
|
||||||
|
// sits inside the animated block so it grows/shrinks with the card.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = freq == RecurrenceFreq.Weekly,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
GroupedSurface(
|
||||||
|
position = Position.Alone,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 16.dp)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
WeekdayToggleRow(
|
WeekdayToggleRow(
|
||||||
selected = daysMask.toDaySet(),
|
selected = daysMask.toDaySet(),
|
||||||
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
||||||
locale = locale,
|
locale = locale,
|
||||||
|
firstDay = firstDayOfWeek,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ends: a connected never / on-a-date / after-N group, the count
|
||||||
|
// field folding into the bottom of the run when chosen.
|
||||||
|
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.event_edit_recurrence_ends),
|
text = stringResource(R.string.event_edit_recurrence_ends),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.event_edit_recurrence_end_never),
|
title = stringResource(R.string.event_edit_recurrence_end_never),
|
||||||
position = positionOf(0, 3),
|
position = Position.Top,
|
||||||
selected = endMode == RecurrenceEndMode.Never,
|
selected = endMode == RecurrenceEndMode.Never,
|
||||||
|
trailing = if (endMode == RecurrenceEndMode.Never) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { endMode = RecurrenceEndMode.Never },
|
onClick = { endMode = RecurrenceEndMode.Never },
|
||||||
)
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.event_edit_recurrence_end_until),
|
title = stringResource(R.string.event_edit_recurrence_end_until),
|
||||||
summary = untilDate?.let {
|
summary = untilSummary,
|
||||||
remember(it, locale) {
|
position = Position.Middle,
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
|
||||||
.withLocale(locale).format(it.toJavaLocalDate())
|
|
||||||
}
|
|
||||||
},
|
|
||||||
position = positionOf(1, 3),
|
|
||||||
selected = endMode == RecurrenceEndMode.Until,
|
selected = endMode == RecurrenceEndMode.Until,
|
||||||
|
trailing = if (endMode == RecurrenceEndMode.Until) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
endMode = RecurrenceEndMode.Until
|
endMode = RecurrenceEndMode.Until
|
||||||
showUntilPicker = true
|
showUntilPicker = true
|
||||||
@@ -1314,14 +1570,34 @@ private fun RecurrencePickerDialog(
|
|||||||
)
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.event_edit_recurrence_end_count),
|
title = stringResource(R.string.event_edit_recurrence_end_count),
|
||||||
position = positionOf(2, 3),
|
position = if (endMode == RecurrenceEndMode.Count) {
|
||||||
|
Position.Middle
|
||||||
|
} else {
|
||||||
|
Position.Bottom
|
||||||
|
},
|
||||||
selected = endMode == RecurrenceEndMode.Count,
|
selected = endMode == RecurrenceEndMode.Count,
|
||||||
|
trailing = if (endMode == RecurrenceEndMode.Count) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = { endMode = RecurrenceEndMode.Count },
|
onClick = { endMode = RecurrenceEndMode.Count },
|
||||||
)
|
)
|
||||||
if (endMode == RecurrenceEndMode.Count) {
|
// Animated for the same reason the weekday card is: this is the
|
||||||
|
// picker's other collapsible block, and a bare `if` here made
|
||||||
|
// the ends group snap while the card above it glided.
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = endMode == RecurrenceEndMode.Count,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
GroupedSurface(
|
||||||
|
position = Position.Bottom,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
DialogAmountField(
|
DialogAmountField(
|
||||||
value = countText,
|
value = countText,
|
||||||
@@ -1337,6 +1613,9 @@ private fun RecurrencePickerDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showUntilPicker) {
|
if (showUntilPicker) {
|
||||||
CalendarDatePickerDialog(
|
CalendarDatePickerDialog(
|
||||||
@@ -1347,7 +1626,13 @@ private fun RecurrencePickerDialog(
|
|||||||
untilIso = it.toString()
|
untilIso = it.toString()
|
||||||
showUntilPicker = false
|
showUntilPicker = false
|
||||||
},
|
},
|
||||||
onDismiss = { showUntilPicker = false },
|
onDismiss = {
|
||||||
|
showUntilPicker = false
|
||||||
|
// Backing out of the date picker with nothing chosen would leave
|
||||||
|
// "on date" selected but dateless — a state that can't be saved and
|
||||||
|
// greys out OK with no visible cause. Fall back to "never".
|
||||||
|
if (untilIso == null) endMode = RecurrenceEndMode.Never
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1362,14 +1647,15 @@ private fun WeekdayToggleRow(
|
|||||||
selected: Set<DayOfWeek>,
|
selected: Set<DayOfWeek>,
|
||||||
onToggle: (DayOfWeek) -> Unit,
|
onToggle: (DayOfWeek) -> Unit,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
firstDay: DayOfWeek,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val days = remember(locale) {
|
val days = remember(firstDay) {
|
||||||
val first = WeekFields.of(locale).firstDayOfWeek.toKotlinDayOfWeek()
|
(0 until 7).map { DayOfWeek(((firstDay.isoDayNumber - 1 + it) % 7) + 1) }
|
||||||
(0 until 7).map { DayOfWeek(((first.isoDayNumber - 1 + it) % 7) + 1) }
|
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
days.forEach { day ->
|
days.forEach { day ->
|
||||||
val isSelected = day in selected
|
val isSelected = day in selected
|
||||||
@@ -1422,18 +1708,14 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
|||||||
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */
|
/**
|
||||||
private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape =
|
* Corner shape for a card at [position] in a grouped run. Delegates to the
|
||||||
when (position) {
|
* family primitive with the same radii [GroupedSurface] draws at rest — a local
|
||||||
Position.Alone -> RoundedCornerShape(full)
|
* copy had drifted to 20dp, so a card sharing a run with GroupedRows had
|
||||||
Position.Top -> RoundedCornerShape(
|
* visibly tighter outer corners than the rows above it.
|
||||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
*/
|
||||||
)
|
private fun groupedShape(position: Position): Shape =
|
||||||
Position.Middle -> RoundedCornerShape(small)
|
floretGroupedShape(position, full = 22.dp, small = 6.dp)
|
||||||
Position.Bottom -> RoundedCornerShape(
|
|
||||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
||||||
private fun groupedGap(position: Position): Modifier = when (position) {
|
private fun groupedGap(position: Position): Modifier = when (position) {
|
||||||
@@ -1876,6 +2158,21 @@ private fun EditCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "2:00 PM – 3:00 PM", honouring the user's 12/24-hour setting. Collapses to one
|
||||||
|
* time when both land on the same minute (an instant event shouldn't read as a
|
||||||
|
* range against itself). Dates are deliberately absent — the rows above carry
|
||||||
|
* them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Locale): String {
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val timeFormat = remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }
|
||||||
|
val from = timeFormat.format(start.time.toJavaLocalTime())
|
||||||
|
val to = timeFormat.format(end.time.toJavaLocalTime())
|
||||||
|
return if (from == to) from else "$from – $to"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Borderless text input used inside the cards (and as the headline title).
|
* Borderless text input used inside the cards (and as the headline title).
|
||||||
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ data class EventEditUiState(
|
|||||||
* neither list.
|
* neither list.
|
||||||
*/
|
*/
|
||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
|
/** Recently picked zones, most recent first — the zone picker's shortlist. */
|
||||||
|
val recentTimeZones: List<String> = emptyList(),
|
||||||
/** True while editing an existing event (the calendar is then fixed). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
|||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
@@ -21,6 +22,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -38,6 +40,8 @@ import kotlinx.coroutines.flow.flowOn
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
@@ -50,6 +54,36 @@ import kotlin.time.Duration.Companion.hours
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
||||||
|
* sources want different reminder handling (#49).
|
||||||
|
*/
|
||||||
|
enum class ImportSource {
|
||||||
|
/**
|
||||||
|
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
||||||
|
* It carries no reminders of its own, so the settings default is applied
|
||||||
|
* automatically, exactly like an in-app new event.
|
||||||
|
*/
|
||||||
|
Insert,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
||||||
|
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
||||||
|
* rather than silently applied or suppressed.
|
||||||
|
*/
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pending offer to swap an imported `.ics` event's reminders for the settings
|
||||||
|
* default (#49). [currentReminderCount] is what the file carried (0 or more);
|
||||||
|
* [defaultReminders] is what accepting would set.
|
||||||
|
*/
|
||||||
|
data class ImportReminderPrompt(
|
||||||
|
val currentReminderCount: Int,
|
||||||
|
val defaultReminders: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holds the event form being composed. The form's calendar id resolves to
|
* Holds the event form being composed. The form's calendar id resolves to
|
||||||
* (user pick > last used > first writable); the resolved value is what the UI
|
* (user pick > last used > first writable); the resolved value is what the UI
|
||||||
@@ -78,10 +112,16 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
// their choice. Reset with the form.
|
// their choice. Reset with the form.
|
||||||
private val _remindersTouched = MutableStateFlow(false)
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||||
|
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||||
|
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||||
|
|
||||||
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
/** True when the event to edit couldn't be loaded; the screen closes itself. */
|
||||||
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
val loadFailed: StateFlow<Boolean> = _loadFailed.asStateFlow()
|
||||||
|
|
||||||
|
/** Pending "apply your default reminder?" offer for a `.ics` import; null when none. */
|
||||||
|
val importReminderPrompt: StateFlow<ImportReminderPrompt?> = _importReminderPrompt.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event being edited plus everything the form saw at load time.
|
* The event being edited plus everything the form saw at load time.
|
||||||
* For recurring events the write scope is chosen at save time; the
|
* For recurring events the write scope is chosen at save time; the
|
||||||
@@ -112,7 +152,17 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val allDay: List<Int>,
|
val allDay: List<Int>,
|
||||||
val timedOverrides: Map<Long, List<Int>>,
|
val timedOverrides: Map<Long, List<Int>>,
|
||||||
val allDayOverrides: Map<Long, List<Int>>,
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
|
) {
|
||||||
|
/** The default reminders for an event on [calendarId] of the given kind. */
|
||||||
|
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
|
||||||
|
timedGlobal = timed,
|
||||||
|
allDayGlobal = allDay,
|
||||||
|
timedOverrides = timedOverrides,
|
||||||
|
allDayOverrides = allDayOverrides,
|
||||||
|
calendarId = calendarId,
|
||||||
|
isAllDay = isAllDay,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
val writable: List<CalendarSource>,
|
val writable: List<CalendarSource>,
|
||||||
@@ -172,7 +222,8 @@ class EventEditViewModel @Inject constructor(
|
|||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
allCalendars,
|
allCalendars,
|
||||||
) { local, external, palette, allCalendars ->
|
settingsPrefs.recentTimeZones.flowOn(io),
|
||||||
|
) { local, external, palette, allCalendars, recentTimeZones ->
|
||||||
val form = local.form ?: return@combine null
|
val form = local.form ?: return@combine null
|
||||||
val resolvedId = form.calendarId
|
val resolvedId = form.calendarId
|
||||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||||
@@ -188,14 +239,19 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val pickerCalendars =
|
val pickerCalendars =
|
||||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||||
else external.writable
|
else external.writable
|
||||||
val visibleFields = external.defaultFields + local.revealed
|
// An all-day event is date-anchored, so a zone is meaningless on it —
|
||||||
|
// the field is withheld from both lists rather than shown as a no-op.
|
||||||
|
val offerableFields = EventFormField.entries.toSet() -
|
||||||
|
if (resolved.isAllDay) setOf(EventFormField.Timezone) else emptySet()
|
||||||
|
val visibleFields = (external.defaultFields + local.revealed) intersect offerableFields
|
||||||
EventEditUiState(
|
EventEditUiState(
|
||||||
form = resolved,
|
form = resolved,
|
||||||
calendars = pickerCalendars,
|
calendars = pickerCalendars,
|
||||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||||
saveState = local.saveState,
|
saveState = local.saveState,
|
||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (offerableFields - visibleFields).sorted(),
|
||||||
|
recentTimeZones = recentTimeZones,
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
isManaged = isManaged,
|
isManaged = isManaged,
|
||||||
autofocusTitle = external.autofocusTitle,
|
autofocusTitle = external.autofocusTitle,
|
||||||
@@ -213,6 +269,13 @@ class EventEditViewModel @Inject constructor(
|
|||||||
initialValue = null,
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First day of the week for ordering the recurrence weekday toggles — the
|
||||||
|
* app's "week starts on" preference, matching the calendar views (Auto
|
||||||
|
* falls back to the locale convention).
|
||||||
|
*/
|
||||||
|
val firstDayOfWeek: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||||
* from midnight) anchors the start when the form is opened by tapping a slot
|
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||||
@@ -241,18 +304,39 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh event from a parsed `.ics` file (the single-event "open into
|
* Seed a fresh event from a prefilled [form] — a parsed single-event `.ics`
|
||||||
* the create form" path). [form] already carries the file's fields; its
|
* file ([ImportSource.File]) or an external `ACTION_INSERT` intent (another
|
||||||
* [EventForm.calendarId] is null so the calendar still resolves to the
|
* app/widget creating an event, e.g. Google Maps' "add to calendar";
|
||||||
* last-used/first-writable one, and reminders are frozen as touched so the
|
* [ImportSource.Insert]; #30, #49). [EventForm.calendarId] is null so the
|
||||||
* settings default never overwrites what the file specified. No-op when a
|
* calendar still resolves to the last-used/first-writable one.
|
||||||
* form is already open, so the prefill survives configuration changes.
|
*
|
||||||
|
* Reminders are handled per [source], because the two paths mean different
|
||||||
|
* things by "no reminders":
|
||||||
|
* - [ImportSource.Insert] carries no reminder semantics, so the settings
|
||||||
|
* default is applied automatically like an in-app new event (a form that
|
||||||
|
* somehow did carry reminders keeps them, frozen).
|
||||||
|
* - [ImportSource.File] owns its reminders, so they're frozen as-is; if a
|
||||||
|
* settings default is configured and differs, [importReminderPrompt] offers
|
||||||
|
* to swap it in rather than silently deciding for the user.
|
||||||
|
*
|
||||||
|
* No-op when a form is already open, so the prefill survives configuration
|
||||||
|
* changes.
|
||||||
*/
|
*/
|
||||||
fun openImported(form: EventForm) {
|
fun openImported(form: EventForm, source: ImportSource) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
_remindersTouched.value = true
|
|
||||||
_revealed.value = form.populatedFields()
|
_revealed.value = form.populatedFields()
|
||||||
_form.value = form
|
_form.value = form
|
||||||
|
when (source) {
|
||||||
|
ImportSource.Insert ->
|
||||||
|
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||||
|
else applyDefaultReminder()
|
||||||
|
|
||||||
|
ImportSource.File -> {
|
||||||
|
// Respect the file's own reminders; never silently overwrite them.
|
||||||
|
_remindersTouched.value = true
|
||||||
|
maybePromptImportedReminderDefault(form)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -266,26 +350,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
private fun applyDefaultReminder(calendarId: Long? = null) {
|
private fun applyDefaultReminder(calendarId: Long? = null) {
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return
|
if (_editTarget.value != null || _remindersTouched.value) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val defaults = combine(
|
val defaults = reminderDefaults()
|
||||||
settingsPrefs.defaultReminderMinutes,
|
|
||||||
settingsPrefs.defaultAllDayReminderMinutes,
|
|
||||||
settingsPrefs.perCalendarReminderOverride,
|
|
||||||
settingsPrefs.perCalendarAllDayReminderOverride,
|
|
||||||
) { timed, allDay, timedOv, allDayOv ->
|
|
||||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
|
||||||
}.first()
|
|
||||||
val targetId = calendarId ?: resolvedCalendarId.first()
|
val targetId = calendarId ?: resolvedCalendarId.first()
|
||||||
// Re-check after suspending: bail if the form closed or the user edited.
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val reminders = resolveDefaultReminder(
|
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||||
timedGlobal = defaults.timed,
|
|
||||||
allDayGlobal = defaults.allDay,
|
|
||||||
timedOverrides = defaults.timedOverrides,
|
|
||||||
allDayOverrides = defaults.allDayOverrides,
|
|
||||||
calendarId = targetId,
|
|
||||||
isAllDay = form.isAllDay,
|
|
||||||
)
|
|
||||||
_form.value = form.copy(reminders = reminders)
|
_form.value = form.copy(reminders = reminders)
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
@@ -295,10 +365,60 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot the four settings-default reminder flows into one value. */
|
||||||
|
private suspend fun reminderDefaults(): ReminderDefaults = combine(
|
||||||
|
settingsPrefs.defaultReminderMinutes,
|
||||||
|
settingsPrefs.defaultAllDayReminderMinutes,
|
||||||
|
settingsPrefs.perCalendarReminderOverride,
|
||||||
|
settingsPrefs.perCalendarAllDayReminderOverride,
|
||||||
|
) { timed, allDay, timedOv, allDayOv ->
|
||||||
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
|
}.first()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||||
|
* file often has none while the user still expects their configured default.
|
||||||
|
* Rather than silently deciding, raise a one-time offer to swap in the
|
||||||
|
* settings default — but only when there's a real choice: a default is
|
||||||
|
* configured and it isn't already exactly what the file carried.
|
||||||
|
*/
|
||||||
|
private fun maybePromptImportedReminderDefault(form: EventForm) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val targetId = resolvedCalendarId.first()
|
||||||
|
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
|
||||||
|
// Bail if the form closed or became an edit while we resolved.
|
||||||
|
val current = _form.value ?: return@launch
|
||||||
|
if (_editTarget.value != null) return@launch
|
||||||
|
if (default.isEmpty() || default == current.reminders) return@launch
|
||||||
|
_importReminderPrompt.value = ImportReminderPrompt(
|
||||||
|
currentReminderCount = current.reminders.size,
|
||||||
|
defaultReminders = default,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accept the import prompt: replace the file's reminders with the default. */
|
||||||
|
fun applyImportedReminderDefault() {
|
||||||
|
val prompt = _importReminderPrompt.value ?: return
|
||||||
|
_importReminderPrompt.value = null
|
||||||
|
// Already frozen as touched by openImported; this just swaps the values.
|
||||||
|
update { it.copy(reminders = prompt.defaultReminders) }
|
||||||
|
_revealed.value = _revealed.value + EventFormField.Reminders
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decline the import prompt: keep the file's own reminders untouched. */
|
||||||
|
fun dismissImportedReminderPrompt() {
|
||||||
|
_importReminderPrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
* Load an existing event into the form. [beginMillis]/[endMillis] are the
|
||||||
* tapped occurrence's own times, like on the detail screen. No-op while a
|
* tapped occurrence's own times, like on the detail screen. An external
|
||||||
* form is open, so user edits survive configuration changes.
|
* "edit this event" (`ACTION_EDIT`) that names no occurrence passes
|
||||||
|
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
|
||||||
|
* form loads the event's real times instead of the epoch (mirrors the
|
||||||
|
* detail screen's #48 fallback). No-op while a form is open, so user edits
|
||||||
|
* survive configuration changes.
|
||||||
*/
|
*/
|
||||||
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
|
||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
@@ -312,8 +432,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val zone = TimeZone.currentSystemDefault()
|
val zone = TimeZone.currentSystemDefault()
|
||||||
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone)
|
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||||
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone)
|
?: detail.instance.start.toEpochMilliseconds()
|
||||||
|
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
|
||||||
|
?: detail.instance.end.toEpochMilliseconds()
|
||||||
|
val snapshot = detail.toEditSnapshot(begin, end, zone)
|
||||||
|
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
|
||||||
// Sections holding data must show even when not in the defaults.
|
// Sections holding data must show even when not in the defaults.
|
||||||
_revealed.value = snapshot.form.populatedFields()
|
_revealed.value = snapshot.form.populatedFields()
|
||||||
_form.value = snapshot.form
|
_form.value = snapshot.form
|
||||||
@@ -329,6 +453,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
_remindersTouched.value = false
|
_remindersTouched.value = false
|
||||||
|
_importReminderPrompt.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unfold one optional field, picked in the "more fields" dialog. */
|
/** Unfold one optional field, picked in the "more fields" dialog. */
|
||||||
@@ -344,12 +469,28 @@ class EventEditViewModel @Inject constructor(
|
|||||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||||
fun setAllDay(value: Boolean) {
|
fun setAllDay(value: Boolean) {
|
||||||
update { it.copy(isAllDay = value) }
|
// Going all-day drops any pinned zone: the times become bare dates that
|
||||||
|
// the provider stores UTC-anchored, so a zone could only misrepresent
|
||||||
|
// them. Coming back out of all-day leaves the event on the device zone,
|
||||||
|
// which is the same thing a fresh event gets.
|
||||||
|
update { it.copy(isAllDay = value, timezone = if (value) null else it.timezone) }
|
||||||
// The default reminder differs for all-day vs timed; re-apply the
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin the event to [zoneId], or pass null to follow the device. The zone
|
||||||
|
* rides along as a recent so the picker can offer it without a search next
|
||||||
|
* time — only real picks are remembered, not the device fallback.
|
||||||
|
*/
|
||||||
|
fun setTimezone(zoneId: String?) {
|
||||||
|
update { it.copy(timezone = zoneId) }
|
||||||
|
if (zoneId != null) {
|
||||||
|
viewModelScope.launch { withContext(io) { settingsPrefs.addRecentTimeZone(zoneId) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switching calendars drops any chosen colour: a palette key is
|
* Switching calendars drops any chosen colour: a palette key is
|
||||||
* account-scoped, and a raw colour may be invalid on the new calendar.
|
* account-scoped, and a raw colour may be invalid on the new calendar.
|
||||||
@@ -439,10 +580,15 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_saveState.value = SaveUiState.Saved
|
_saveState.value = SaveUiState.Saved
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Changing the calendar moves the event (copy+delete — CALENDAR_ID can't
|
||||||
|
// be updated in place) and is inherently whole-series: an occurrence
|
||||||
|
// can't live in a different calendar than its series, so a move skips the
|
||||||
|
// scope dialog even for a recurring event.
|
||||||
|
val movingCalendar = target != null && form.calendarId != target.original.calendarId
|
||||||
// Managed events are a yearly series whose editable fields (reminders,
|
// Managed events are a yearly series whose editable fields (reminders,
|
||||||
// notes, location) live on the series row — never offer the scope dialog,
|
// notes, location) live on the series row — never offer the scope dialog,
|
||||||
// which would split the series into an exception the sync then reverts.
|
// which would split the series into an exception the sync then reverts.
|
||||||
if (target != null && target.original.rrule != null && !current.isManaged) {
|
if (target != null && target.original.rrule != null && !current.isManaged && !movingCalendar) {
|
||||||
_saveState.value = SaveUiState.AwaitingScope
|
_saveState.value = SaveUiState.AwaitingScope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -497,6 +643,17 @@ class EventEditViewModel @Inject constructor(
|
|||||||
if (target == null) {
|
if (target == null) {
|
||||||
repository.createEvent(form)
|
repository.createEvent(form)
|
||||||
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
|
} else if (form.calendarId != target.original.calendarId) {
|
||||||
|
// Move to the picked calendar (copy+delete), carrying any
|
||||||
|
// field edits made in the same save. The target becomes the
|
||||||
|
// last-used calendar, like a create does.
|
||||||
|
repository.moveEvent(
|
||||||
|
eventId = target.eventId,
|
||||||
|
targetCalendarId = requireNotNull(form.calendarId),
|
||||||
|
original = target.original,
|
||||||
|
updated = form,
|
||||||
|
)
|
||||||
|
prefs.setLastUsedCalendarId(requireNotNull(form.calendarId))
|
||||||
} else {
|
} else {
|
||||||
when (scope) {
|
when (scope) {
|
||||||
RecurringWriteScope.ThisEvent ->
|
RecurringWriteScope.ThisEvent ->
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What lets the split style's compact grid *become* the full month rather than be
|
||||||
|
* swapped for it (#53).
|
||||||
|
*
|
||||||
|
* The two grids are separate composables — the compact one draws dots, the
|
||||||
|
* expanded one draws the paged style's bars and pills — so nothing about them is
|
||||||
|
* shared by construction. Tagging the pieces that mean the same thing on both
|
||||||
|
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
|
||||||
|
* the other: a day's cell grows, its number rides along, and each dot travels out
|
||||||
|
* to the bar it stood for.
|
||||||
|
*
|
||||||
|
* It travels as a composition local rather than as parameters because the pieces
|
||||||
|
* sit five levels below where the scopes exist, and because a null default is
|
||||||
|
* exactly the right meaning for everyone else: the paged, continuous, and dense
|
||||||
|
* styles share the same row and cell composables, provide nothing, and pay
|
||||||
|
* nothing. Reduced motion provides nothing either, which leaves the plain
|
||||||
|
* cross-fade underneath.
|
||||||
|
*/
|
||||||
|
internal sealed interface MonthMorphKey {
|
||||||
|
/** A day's background pill — the structural anchor the rest rides on. */
|
||||||
|
data class Cell(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
data class DayNumber(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event *on one day*. Keyed by date as well as instance because a
|
||||||
|
* multi-day event has a dot on every day it covers but only one bar, drawn
|
||||||
|
* from where it starts: the start day's dot is the one that becomes the bar,
|
||||||
|
* and the rest are left unmatched on purpose. They fade where they stand
|
||||||
|
* while the bar sweeps out over them, which is the honest reading — one of
|
||||||
|
* them could not become the bar without the others teleporting into it.
|
||||||
|
*/
|
||||||
|
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day's "+N" marker. The compact grid writes it as a count beside the dots
|
||||||
|
* and the expanded one as a row of small dots under the bars, but they stand
|
||||||
|
* for the same events and appear on exactly the same days — both sides seat
|
||||||
|
* three and overflow the rest — so they are a matched pair, not two unrelated
|
||||||
|
* bits of text. Tagged, the marker travels with its day like everything else
|
||||||
|
* in the cell.
|
||||||
|
*/
|
||||||
|
data class Overflow(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected day's outline. The expanded grid draws no outline — once the
|
||||||
|
* cells carry real event bars it is one mark too many — so this pairs the
|
||||||
|
* compact grid's outline with an *invisible* stand-in over the same cell
|
||||||
|
* there. Without one it had nowhere to travel and could only fade in place
|
||||||
|
* while everything around it moved.
|
||||||
|
*/
|
||||||
|
data class Selection(val date: LocalDate) : MonthMorphKey
|
||||||
|
|
||||||
|
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
|
||||||
|
data object Handle : MonthMorphKey
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
internal class MonthMorphScope(
|
||||||
|
val shared: SharedTransitionScope,
|
||||||
|
val visibility: AnimatedVisibilityScope,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the grid is mid-morph, for content that has to stop clipping to let
|
||||||
|
* the travelling pieces through.
|
||||||
|
*
|
||||||
|
* A mark's two homes are in *different rows*: the dot for the 20th sits a third
|
||||||
|
* of the way down the compact grid, its bar most of the way down the expanded
|
||||||
|
* one. Clipped to the row it is arriving at, a mark spends the first half of its
|
||||||
|
* journey outside those bounds and is simply not drawn — so it appears from
|
||||||
|
* nowhere, part-way through, already near its destination. Rendering in place
|
||||||
|
* rather than in an overlay is what subjects them to that clip, and is worth
|
||||||
|
* keeping; the clip is what has to yield, and only while pieces are in flight.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun morphInFlight(): Boolean {
|
||||||
|
val morph = LocalMonthMorph.current ?: return false
|
||||||
|
return morph.shared.isTransitionActive
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag content that is *the same thing* on both sides — a background pill, a day
|
||||||
|
* number — so it animates between its two positions and sizes.
|
||||||
|
*
|
||||||
|
* ### Why nothing renders in the overlay
|
||||||
|
*
|
||||||
|
* By default a travelling piece is painted into an overlay above the *entire*
|
||||||
|
* regular tree, so it can fly over anything in its way. That is right for a
|
||||||
|
* thumbnail crossing a screen, and wrong here: everything is moving inside one
|
||||||
|
* grid, and the overlay meant every untagged neighbour — the selection outline,
|
||||||
|
* the "+N" markers — spent the transition buried under pieces that had left the
|
||||||
|
* tree's z-order behind. Lifting each of them out in turn fixed the burying and
|
||||||
|
* bought a worse problem: they then floated over the grid on their own layer,
|
||||||
|
* out of step with it.
|
||||||
|
*
|
||||||
|
* Rendering in place puts everything back in one z-order and one clip, which is
|
||||||
|
* what makes the grid read as a single surface changing shape rather than a
|
||||||
|
* stack of pieces sliding past each other. Nothing here needs to escape its
|
||||||
|
* ancestors: the cells, marks and markers all travel within the grid.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
||||||
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphElement.sharedElement(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
renderInOverlayDuringTransition = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag content that means the same thing but *is drawn differently* on each side —
|
||||||
|
* a 5dp dot and a titled bar — so only the bounds are shared and the contents
|
||||||
|
* cross-fade inside them.
|
||||||
|
*
|
||||||
|
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
|
||||||
|
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
|
||||||
|
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
|
||||||
|
* simply clips it while there is no room, so what grows is the pill, not the type.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||||
|
val morph = LocalMonthMorph.current ?: return this
|
||||||
|
return with(morph.shared) {
|
||||||
|
this@morphBounds.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key),
|
||||||
|
animatedVisibilityScope = morph.visibility,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||||
|
renderInOverlayDuringTransition = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||||
|
* chooser.
|
||||||
|
*
|
||||||
|
* It renders the *real* grid composables rather than a drawing of them, so the
|
||||||
|
* preview cannot drift from the thing it depicts: change a cell's shape or an
|
||||||
|
* event bar's colour and every preview follows automatically. The trick is
|
||||||
|
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
|
||||||
|
* out at a full phone width and a plausible viewport height, and a
|
||||||
|
* [graphicsLayer] scale shrinks the finished layout into the card.
|
||||||
|
*
|
||||||
|
* The month, today's position and the week start are all real; only the events
|
||||||
|
* are stand-ins, since the settings screen has no business querying the
|
||||||
|
* provider for a thumbnail.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun MonthStylePreview(
|
||||||
|
style: MonthViewStyle,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
|
val scale = height.value / VIRTUAL_HEIGHT.value
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clipToBounds()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// A preview is a picture, not a control: swallow touches before the
|
||||||
|
// grid's own clickables see them, and give screen readers one label
|
||||||
|
// instead of six weeks of day cells.
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||||
|
.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
// Measure the grid at a full phone viewport but report the scaled
|
||||||
|
// size, so the node occupies exactly what it draws.
|
||||||
|
//
|
||||||
|
// Modifier.requiredSize would be the obvious way to force the larger
|
||||||
|
// measurement, but it *centres* content that overflows the incoming
|
||||||
|
// constraints — which pushed the grid to a negative offset and left
|
||||||
|
// only its bottom-right corner inside the clip.
|
||||||
|
.layout { measurable, _ ->
|
||||||
|
val fullWidth = screenWidth.roundToPx()
|
||||||
|
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
|
||||||
|
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||||
|
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||||
|
placeable.place(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||||
|
when (style) {
|
||||||
|
MonthViewStyle.Paged -> MonthGrid(
|
||||||
|
state = sample.month,
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||||
|
state = sample.continuous,
|
||||||
|
listState = rememberLazyListState(
|
||||||
|
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||||
|
monthIndexOf(YearMonth(today.year, today.month)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||||
|
state = sample.continuous,
|
||||||
|
listState = rememberLazyListState(
|
||||||
|
// Start a week above today's, so the preview shows a
|
||||||
|
// stream running past the viewport rather than one
|
||||||
|
// beginning at its top edge.
|
||||||
|
initialFirstVisibleItemIndex =
|
||||||
|
weekIndexOf(today, weekStart) - 1,
|
||||||
|
),
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onOpenDay = {},
|
||||||
|
)
|
||||||
|
MonthViewStyle.Split -> {
|
||||||
|
SplitMonthGrid(
|
||||||
|
state = sample.month,
|
||||||
|
selected = today,
|
||||||
|
showWeekNumbers = false,
|
||||||
|
onSelectDay = {},
|
||||||
|
)
|
||||||
|
SplitDayPane(
|
||||||
|
date = today,
|
||||||
|
today = today,
|
||||||
|
events = sample.month.instancesByDay[today].orEmpty(),
|
||||||
|
zone = zone,
|
||||||
|
onOpenDay = {},
|
||||||
|
onEventClick = {},
|
||||||
|
onCreateEvent = {},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The viewport the preview pretends to be: a phone's width by the height a
|
||||||
|
* calendar view gets under the top bar. Scaling from a fixed height keeps the
|
||||||
|
* three styles comparable — each shows the same slice of screen.
|
||||||
|
*/
|
||||||
|
private val VIRTUAL_HEIGHT = 440.dp
|
||||||
|
private val SPLIT_PANE_HEIGHT = 170.dp
|
||||||
|
|
||||||
|
private class SampleMonth(
|
||||||
|
val month: MonthUiState.Success,
|
||||||
|
val continuous: ContinuousMonthUiState.Success,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A month's worth of stand-in events, laid out through the same
|
||||||
|
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
||||||
|
* exercises the real span, lane and overflow logic rather than approximating it.
|
||||||
|
*
|
||||||
|
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||||
|
* event, so a token here would misrepresent what the grid actually renders.
|
||||||
|
*/
|
||||||
|
private fun sampleMonthState(
|
||||||
|
today: LocalDate,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
zone: TimeZone,
|
||||||
|
): SampleMonth {
|
||||||
|
val ym = YearMonth(today.year, today.month)
|
||||||
|
val first = LocalDate(ym.year, ym.month, 1)
|
||||||
|
val events = sampleEvents(first, today, zone)
|
||||||
|
|
||||||
|
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||||
|
val month = MonthUiState.Success(
|
||||||
|
month = ym,
|
||||||
|
today = today,
|
||||||
|
weeks = weeks,
|
||||||
|
instancesByDay = instancesByDay(weeks.flatMap { it.days }, events, zone),
|
||||||
|
zone = zone,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The month either side of this one: enough for Continuous to show a block,
|
||||||
|
// the whitespace under it and the next month's header coming up, and for
|
||||||
|
// Dense to have somewhere to scroll in both directions.
|
||||||
|
val centre = monthIndexOf(ym)
|
||||||
|
val window = (centre - 1)..(centre + 1)
|
||||||
|
val continuous = ContinuousMonthUiState.Success(
|
||||||
|
today = today,
|
||||||
|
monthsByIndex = window.associateWith { index ->
|
||||||
|
val m = yearMonthForIndex(index)
|
||||||
|
layoutMonthWeeks(m, weekStart, events, zone).map { clipWeekToMonth(it, m) }
|
||||||
|
},
|
||||||
|
weeksByIndex = weekWindowFor(window, weekStart).associateWith { index ->
|
||||||
|
val days = (0 until 7).map {
|
||||||
|
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
layoutCalendarWeek(days, events, zone)
|
||||||
|
},
|
||||||
|
weekStart = weekStart,
|
||||||
|
)
|
||||||
|
return SampleMonth(month, continuous)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SAMPLE_COLORS = listOf(
|
||||||
|
0xFF3F7BD4.toInt(),
|
||||||
|
0xFFCE5B4C.toInt(),
|
||||||
|
0xFF4E9A6A.toInt(),
|
||||||
|
0xFF8A63C7.toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun sampleEvents(
|
||||||
|
firstOfMonth: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<EventInstance> {
|
||||||
|
var id = 0L
|
||||||
|
fun next() = ++id
|
||||||
|
|
||||||
|
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
||||||
|
instanceId = next(),
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||||
|
start = day.atTime(hour, 0).toInstant(zone),
|
||||||
|
end = day.atTime(hour + length, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
||||||
|
instanceId = next(),
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||||
|
// All-day events sit at UTC midnights with an exclusive end.
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spread across the month so most weeks carry something, with one multi-day
|
||||||
|
// bar to show a span bridging cells and a busy today for the split pane.
|
||||||
|
return buildList {
|
||||||
|
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||||
|
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
||||||
|
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
||||||
|
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
||||||
|
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
||||||
|
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
||||||
|
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
||||||
|
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
||||||
|
// Today, so the split pane's list and the grid's dots both have content.
|
||||||
|
add(timed(today, 9, 1, 0))
|
||||||
|
add(timed(today, 12, 1, 1))
|
||||||
|
add(timed(today, 15, 2, 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val SAMPLE_TITLES = listOf(
|
||||||
|
"Standup",
|
||||||
|
"Lunch",
|
||||||
|
"Review",
|
||||||
|
"Gym",
|
||||||
|
"Call",
|
||||||
|
"Workshop",
|
||||||
|
"Dentist",
|
||||||
|
"Trip",
|
||||||
|
)
|
||||||
@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.month
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,12 +39,85 @@ data class MonthWeek(
|
|||||||
val countByDay: Map<LocalDate, Int>,
|
val countByDay: Map<LocalDate, Int>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||||
|
* order, capped at [laneCap] lanes.
|
||||||
|
*
|
||||||
|
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
|
||||||
|
* week: bars keep the lane the row layout gave them, and the day's timed events
|
||||||
|
* fill whatever slots are left, top-most first. Reading it here means the split
|
||||||
|
* style's dots and the paged style's bars describe a day in the *same order*, so
|
||||||
|
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
|
||||||
|
*
|
||||||
|
* Deriving the dots independently — by distinct colour, as they first were —
|
||||||
|
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||||
|
* that shared a calendar into a single dot.
|
||||||
|
*/
|
||||||
|
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||||
|
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||||
|
spans.forEach { span ->
|
||||||
|
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||||
|
byLane[span.lane] = span.event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||||
|
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||||
|
byLane[free[i]] = event
|
||||||
|
}
|
||||||
|
return byLane.filterNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||||
|
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||||
|
* absolute month index and carries only its own days — the boundary week keeps
|
||||||
|
* its seven columns so the grid geometry never shifts, but the neighbour month's
|
||||||
|
* dates are clipped out of it (see `clipWeekToMonth`) and render as blanks.
|
||||||
|
*
|
||||||
|
* The same loaded window is served two ways, because the Dense style is the same
|
||||||
|
* scroll with the seams taken out and it would be wasteful to query the provider
|
||||||
|
* twice for one range:
|
||||||
|
*
|
||||||
|
* - [monthsByIndex] — the block layout, keyed by absolute month index, each week
|
||||||
|
* clipped to its own month.
|
||||||
|
* - [weeksByIndex] — the flowing layout, keyed by absolute week index, boundary
|
||||||
|
* weeks left carrying both months.
|
||||||
|
*
|
||||||
|
* Both hold only the loaded window; indices outside it render as placeholders
|
||||||
|
* until the window catches up.
|
||||||
|
*/
|
||||||
|
sealed interface ContinuousMonthUiState {
|
||||||
|
data object Loading : ContinuousMonthUiState
|
||||||
|
data class Failure(val reason: FailureReason) : ContinuousMonthUiState
|
||||||
|
data class Success(
|
||||||
|
val today: LocalDate,
|
||||||
|
val monthsByIndex: Map<Int, List<MonthWeek>>,
|
||||||
|
val weeksByIndex: Map<Int, MonthWeek>,
|
||||||
|
val weekStart: DayOfWeek,
|
||||||
|
) : ContinuousMonthUiState
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface MonthUiState {
|
sealed interface MonthUiState {
|
||||||
data object Loading : MonthUiState
|
data object Loading : MonthUiState
|
||||||
data class Failure(val reason: FailureReason) : MonthUiState
|
data class Failure(val reason: FailureReason) : MonthUiState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [weeks] is what the grid draws; [instancesByDay] is the same events keyed by
|
||||||
|
* date and *uncapped*, so the split style's day pane can list everything on a
|
||||||
|
* date without a second provider query — the month grid range already covers
|
||||||
|
* it. All-day events sort first, then by start.
|
||||||
|
*/
|
||||||
data class Success(
|
data class Success(
|
||||||
val month: YearMonth,
|
val month: YearMonth,
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val weeks: List<MonthWeek>,
|
val weeks: List<MonthWeek>,
|
||||||
|
val instancesByDay: Map<LocalDate, List<EventInstance>> = emptyMap(),
|
||||||
|
/**
|
||||||
|
* Travels on the state rather than being read at the call site, for the
|
||||||
|
* same reason the agenda does it: a file-level constant would be fixed
|
||||||
|
* for the process lifetime and drift from the zone the events were laid
|
||||||
|
* out in after a device time-zone change.
|
||||||
|
*/
|
||||||
|
val zone: TimeZone = TimeZone.currentSystemDefault(),
|
||||||
) : MonthUiState
|
) : MonthUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -20,21 +20,23 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.daysUntil
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -48,16 +50,9 @@ class MonthViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
private val locale: Locale = Locale.getDefault()
|
|
||||||
|
|
||||||
/** First day of the week, from the Settings preference (AUTO → locale). */
|
/** First day of the week, from the Settings preference (AUTO → locale). */
|
||||||
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.weekStart
|
val weekStart: StateFlow<DayOfWeek> = settingsPrefs.firstDayOfWeek(viewModelScope)
|
||||||
.map { it.resolveFirstDay(locale) }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = DayOfWeek.MONDAY,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Whether to fade events that have already finished (display concern only). */
|
/** Whether to fade events that have already finished (display concern only). */
|
||||||
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||||
@@ -75,6 +70,14 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** How the grid is laid out and navigated (#38, #53). */
|
||||||
|
val viewStyle: StateFlow<MonthViewStyle> = settingsPrefs.monthViewStyle
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = MonthViewStyle.Paged,
|
||||||
|
)
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
@@ -82,8 +85,12 @@ class MonthViewModel @Inject constructor(
|
|||||||
val month: StateFlow<YearMonth> = _month
|
val month: StateFlow<YearMonth> = _month
|
||||||
|
|
||||||
val state: StateFlow<MonthUiState> =
|
val state: StateFlow<MonthUiState> =
|
||||||
combine(_month, weekStart) { ym, ws -> ym to ws }
|
combine(_month, weekStart, viewStyle) { ym, ws, style -> Triple(ym, ws, style) }
|
||||||
.flatMapLatest { (ym, ws) ->
|
.flatMapLatest { (ym, ws, style) ->
|
||||||
|
// The mirror of the gate below: the paged grid is what Paged and
|
||||||
|
// Split draw, so under a scrolling style this query is a month
|
||||||
|
// of provider work for a view that isn't on screen.
|
||||||
|
if (style.isScrolling) return@flatMapLatest flowOf(MonthUiState.Loading)
|
||||||
val range = monthGridRange(ym, ws, zone)
|
val range = monthGridRange(ym, ws, zone)
|
||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
@@ -100,21 +107,202 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = MonthUiState.Loading,
|
initialValue = MonthUiState.Loading,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// --- Continuous + Dense styles (#38) ----------------------------------
|
||||||
|
//
|
||||||
|
// These scroll through every month there is, so they can't load "a month" —
|
||||||
|
// they load a sliding window of month indices around whatever is on screen.
|
||||||
|
// The window only moves when the visible range comes within WINDOW_EDGE
|
||||||
|
// months of a loaded edge, so a scroll re-queries occasionally rather than
|
||||||
|
// on every frame.
|
||||||
|
//
|
||||||
|
// The window also *starts small and grows*. A provider Instances query
|
||||||
|
// expands recurrences across its whole range, so opening straight onto a
|
||||||
|
// year of months made the first frame wait for eleven months of expansion
|
||||||
|
// when only one was about to be looked at. It now opens on INITIAL_PAD
|
||||||
|
// months, and each completed load widens by GROWTH_STEP in both directions
|
||||||
|
// until MAX_PAD — the months you can reach by scrolling arrive while you're
|
||||||
|
// still looking at the first one.
|
||||||
|
|
||||||
|
/** How far around the visible range we currently load; grows as loads land. */
|
||||||
|
@Volatile
|
||||||
|
private var loadPad = INITIAL_PAD
|
||||||
|
|
||||||
|
/** Last reported visible range, so a widening step can re-centre on it. */
|
||||||
|
@Volatile
|
||||||
|
private var visibleMonths = monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||||
|
.let { it..it }
|
||||||
|
|
||||||
|
private val _loadedMonths = MutableStateFlow(
|
||||||
|
monthIndexOf(YearMonth(todayDate.year, todayDate.month))
|
||||||
|
.let { clampMonthWindow(it - INITIAL_PAD..it + INITIAL_PAD) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val continuousState: StateFlow<ContinuousMonthUiState> =
|
||||||
|
combine(_loadedMonths, weekStart, viewStyle) { window, ws, style ->
|
||||||
|
Triple(window, ws, style)
|
||||||
|
}
|
||||||
|
.flatMapLatest { (window, ws, style) ->
|
||||||
|
// Nothing to load for the paged and split styles — they have
|
||||||
|
// their own single-month flow, and querying a year of months
|
||||||
|
// behind them is pure waste.
|
||||||
|
if (!style.isScrolling) return@flatMapLatest flowOf(ContinuousMonthUiState.Loading)
|
||||||
|
// Widened to whole grid weeks at both ends: a month block still
|
||||||
|
// has to know about an event that starts in the boundary week's
|
||||||
|
// clipped-off days, or a bar running into the block would vanish.
|
||||||
|
val first = firstOfMonth(yearMonthForIndex(window.first)).startOfGridWeek(ws)
|
||||||
|
val last = firstOfMonth(yearMonthForIndex(window.last))
|
||||||
|
.plus(1, DateTimeUnit.MONTH)
|
||||||
|
.minus(1, DateTimeUnit.DAY)
|
||||||
|
.startOfGridWeek(ws)
|
||||||
|
.plus(6, DateTimeUnit.DAY)
|
||||||
|
val range = first.atStartOfDayIn(zone)..last.atTime(23, 59, 59).toInstant(zone)
|
||||||
|
combine(
|
||||||
|
repository.calendars(),
|
||||||
|
repository.instances(range),
|
||||||
|
) { calendars, instances ->
|
||||||
|
buildContinuousState(window, ws, style, calendars, instances)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A load landing is the cue to reach further out. Widening from here
|
||||||
|
// rather than on a timer means each step waits for the previous one,
|
||||||
|
// so the ladder can never outrun the provider.
|
||||||
|
.onEach { if (it is ContinuousMonthUiState.Success) widenLoadedWindow() }
|
||||||
|
.catch { emit(ContinuousMonthUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = ContinuousMonthUiState.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report which absolute month indices are on screen. Cheap to call on every
|
||||||
|
* scroll frame: it returns immediately unless the visible range has drifted
|
||||||
|
* close enough to a loaded edge to warrant a wider query.
|
||||||
|
*/
|
||||||
|
fun onVisibleMonthsChanged(firstIndex: Int, lastIndex: Int) {
|
||||||
|
visibleMonths = firstIndex..lastIndex
|
||||||
|
nextLoadWindow(_loadedMonths.value, firstIndex, lastIndex, loadPad)
|
||||||
|
?.let { _loadedMonths.value = clampMonthWindow(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One rung up the ladder: reach [GROWTH_STEP] further in each direction,
|
||||||
|
* stopping at [MAX_PAD]. A no-op once there, so the provider notifications
|
||||||
|
* that re-emit the same window don't restart it.
|
||||||
|
*/
|
||||||
|
private fun widenLoadedWindow() {
|
||||||
|
if (loadPad >= MAX_PAD) return
|
||||||
|
loadPad = (loadPad + GROWTH_STEP).coerceAtMost(MAX_PAD)
|
||||||
|
_loadedMonths.value = clampMonthWindow(
|
||||||
|
visibleMonths.first - loadPad..visibleMonths.last + loadPad,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildContinuousState(
|
||||||
|
window: IntRange,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
style: MonthViewStyle,
|
||||||
|
calendars: List<CalendarSource>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
): ContinuousMonthUiState {
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
|
}
|
||||||
|
// Bucketed once for the whole window: a row then takes the handful of
|
||||||
|
// events on its seven days instead of re-scanning a year of them.
|
||||||
|
val byDay = DayIndex(instances, zone)
|
||||||
|
// Only the style on screen is laid out. Building both doubled the work
|
||||||
|
// for a layout nothing was going to draw.
|
||||||
|
val months = if (style == MonthViewStyle.Continuous) {
|
||||||
|
window.associateWith { index ->
|
||||||
|
val ym = yearMonthForIndex(index)
|
||||||
|
layoutMonthWeeks(ym, weekStart, byDay, zone).map { clipWeekToMonth(it, ym) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
val weeks = if (style == MonthViewStyle.Dense) {
|
||||||
|
weekWindowFor(window, weekStart).associateWith { index ->
|
||||||
|
val days = (0 until 7).map {
|
||||||
|
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
layoutCalendarWeek(days, byDay.eventsOn(days), zone)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
return ContinuousMonthUiState.Success(
|
||||||
|
today = todayDate,
|
||||||
|
monthsByIndex = months,
|
||||||
|
weeksByIndex = weeks,
|
||||||
|
weekStart = weekStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Split style (#53) ------------------------------------------------
|
||||||
|
//
|
||||||
|
// The first selected-day concept in the app: the other views drill straight
|
||||||
|
// into a date, while the split style keeps one selected and lists it below
|
||||||
|
// the grid.
|
||||||
|
|
||||||
|
private val _selectedDate = MutableStateFlow(todayDate)
|
||||||
|
val selectedDate: StateFlow<LocalDate> = _selectedDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select [date], following it to its month if it sits in the grid's leading
|
||||||
|
* or trailing days — tapping a greyed-out day should show that day, not
|
||||||
|
* silently list a date the grid isn't pointing at.
|
||||||
|
*/
|
||||||
|
fun selectDate(date: LocalDate) {
|
||||||
|
_selectedDate.value = date
|
||||||
|
val target = YearMonth(date.year, date.month)
|
||||||
|
if (target != _month.value) _month.value = target
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the selection with the month: today if the new month holds it, else
|
||||||
|
* its 1st. Leaving the old date selected would list a day the grid no longer
|
||||||
|
* shows.
|
||||||
|
*/
|
||||||
|
private fun realignSelection() {
|
||||||
|
_selectedDate.value = selectionForMonth(_month.value, todayDate)
|
||||||
|
}
|
||||||
|
|
||||||
fun goToPrev() {
|
fun goToPrev() {
|
||||||
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.minus(1, DateTimeUnit.MONTH)
|
||||||
|
realignSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToNext() {
|
fun goToNext() {
|
||||||
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
_month.value = _month.value.plus(1, DateTimeUnit.MONTH)
|
||||||
|
realignSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goToToday() {
|
fun goToToday() {
|
||||||
_month.value = YearMonth(todayDate.year, todayDate.month)
|
_month.value = YearMonth(todayDate.year, todayDate.month)
|
||||||
|
_selectedDate.value = todayDate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track [_month] to the month a scrolling style is showing. Those styles
|
||||||
|
* navigate by scroll position and never set [_month] themselves, so it would
|
||||||
|
* otherwise sit wherever paged navigation last left it (today's month, on a
|
||||||
|
* fresh open) — and a switch to a paged style, or a reseed of the other
|
||||||
|
* scrolling style's list, would jump there instead of holding position. The
|
||||||
|
* selection is realigned alongside it so that landing on the Split style
|
||||||
|
* shows a live day in the month on screen rather than a stale, off-month one.
|
||||||
|
*/
|
||||||
|
fun syncScrollMonth(ym: YearMonth) {
|
||||||
|
if (_month.value == ym) return
|
||||||
|
_month.value = ym
|
||||||
|
_selectedDate.value = selectionForMonth(ym, todayDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump to the month containing [date] (drawer jump-to-date). */
|
/** Jump to the month containing [date] (drawer jump-to-date). */
|
||||||
fun goToDate(date: LocalDate) {
|
fun goToDate(date: LocalDate) {
|
||||||
_month.value = YearMonth(date.year, date.month)
|
_month.value = YearMonth(date.year, date.month)
|
||||||
|
_selectedDate.value = date
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
@@ -126,10 +314,13 @@ class MonthViewModel @Inject constructor(
|
|||||||
if (calendars.isEmpty()) {
|
if (calendars.isEmpty()) {
|
||||||
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
|
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
|
||||||
return MonthUiState.Success(
|
return MonthUiState.Success(
|
||||||
month = ym,
|
month = ym,
|
||||||
today = todayDate,
|
today = todayDate,
|
||||||
weeks = layoutMonthWeeks(ym, weekStart, instances, zone),
|
weeks = weeks,
|
||||||
|
instancesByDay = instancesByDay(weeks.flatMap { it.days }, instances, zone),
|
||||||
|
zone = zone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,16 +339,83 @@ internal fun layoutMonthWeeks(
|
|||||||
weekStart: DayOfWeek,
|
weekStart: DayOfWeek,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
zone: TimeZone,
|
zone: TimeZone,
|
||||||
|
): List<MonthWeek> = layoutMonthWeeks(ym, weekStart, DayIndex(instances, zone), zone)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* As above, but reusing a [DayIndex] already built for a wider range — what the
|
||||||
|
* scrolling styles need, since they lay out many months from one query and
|
||||||
|
* rebuilding the index per month would put the cost straight back.
|
||||||
|
*/
|
||||||
|
internal fun layoutMonthWeeks(
|
||||||
|
ym: YearMonth,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
byDay: DayIndex,
|
||||||
|
zone: TimeZone,
|
||||||
): List<MonthWeek> {
|
): List<MonthWeek> {
|
||||||
val firstOfMonth = LocalDate(ym.year, ym.month, 1)
|
val gridStart = firstOfMonth(ym).startOfGridWeek(weekStart)
|
||||||
val gridStart = firstOfMonth.startOfGridWeek(weekStart)
|
val weekCount = weekRowsInMonth(ym, weekStart)
|
||||||
val leadOffset = ((firstOfMonth.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
|
||||||
val daysInMonth =
|
|
||||||
java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
|
||||||
val weekCount = (leadOffset + daysInMonth + 6) / 7
|
|
||||||
|
|
||||||
return (0 until weekCount).map { row ->
|
return (0 until weekCount).map { row ->
|
||||||
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
val days = (0 until 7).map { gridStart.plus(row * 7 + it, DateTimeUnit.DAY) }
|
||||||
|
layoutCalendarWeek(days, byDay.eventsOn(days), zone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events bucketed by the dates they cover, built once per load.
|
||||||
|
*
|
||||||
|
* [layoutCalendarWeek] opens by filtering the instances it was handed down to
|
||||||
|
* the ones touching its seven days. That is fine for a single month's grid, but
|
||||||
|
* the scrolling styles lay out a hundred-odd rows from one query, and scanning
|
||||||
|
* *every* instance in an eleven-month window for each of them made the work grow
|
||||||
|
* with the number of enabled calendars until opening the view stalled.
|
||||||
|
*
|
||||||
|
* Membership is decided by [coversDay] itself rather than by re-deriving the
|
||||||
|
* rule from the timestamps: the all-day and timed cases have enough edge cases
|
||||||
|
* between them (UTC anchoring, exclusive ends, zero-length events at midnight)
|
||||||
|
* that a second implementation would drift. The walk only visits an event's own
|
||||||
|
* candidate days, so the check runs a couple of times per event rather than once
|
||||||
|
* per event per row.
|
||||||
|
*/
|
||||||
|
internal class DayIndex(instances: List<EventInstance>, private val zone: TimeZone) {
|
||||||
|
|
||||||
|
private val byDay: Map<LocalDate, List<EventInstance>> = buildMap<LocalDate, MutableList<EventInstance>> {
|
||||||
|
instances.forEach { event ->
|
||||||
|
// All-day events are anchored to UTC midnights; timed ones to the
|
||||||
|
// device zone. Either way the candidate span is the event's own
|
||||||
|
// dates, which is one or two days for almost everything.
|
||||||
|
val anchor = if (event.isAllDay) TimeZone.UTC else zone
|
||||||
|
var day = event.start.toLocalDateTime(anchor).date
|
||||||
|
val lastCandidate = event.end.toLocalDateTime(anchor).date
|
||||||
|
while (day <= lastCandidate) {
|
||||||
|
if (event.coversDay(day, zone)) getOrPut(day) { mutableListOf() }.add(event)
|
||||||
|
day = day.plus(1, DateTimeUnit.DAY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every event touching any of [days], each listed once, in input order. */
|
||||||
|
fun eventsOn(days: List<LocalDate>): List<EventInstance> {
|
||||||
|
if (days.isEmpty()) return emptyList()
|
||||||
|
val hits = days.flatMap { byDay[it].orEmpty() }
|
||||||
|
// A multi-day event is bucketed on each date it covers, so a row that
|
||||||
|
// holds several of its days would otherwise list it several times.
|
||||||
|
return if (hits.size < 2) hits else hits.distinctBy { it.instanceId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve one week row's events for rendering. Split out of [layoutMonthWeeks] so
|
||||||
|
* the continuous style — which streams weeks rather than months and so has no
|
||||||
|
* enclosing [YearMonth] to slice by — lays each row out identically.
|
||||||
|
*
|
||||||
|
* [days] must be the row's seven consecutive dates, in display order.
|
||||||
|
*/
|
||||||
|
internal fun layoutCalendarWeek(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): MonthWeek {
|
||||||
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
|
||||||
val (bars, singles) = weekEvents.partition { ev ->
|
val (bars, singles) = weekEvents.partition { ev ->
|
||||||
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
|
||||||
@@ -172,7 +430,7 @@ internal fun layoutMonthWeeks(
|
|||||||
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
continuesRight = s.event.coversDay(days.last().plus(1, DateTimeUnit.DAY), zone),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MonthWeek(
|
return MonthWeek(
|
||||||
days = days,
|
days = days,
|
||||||
spans = spans,
|
spans = spans,
|
||||||
timedByDay = days.associateWith { d ->
|
timedByDay = days.associateWith { d ->
|
||||||
@@ -180,9 +438,25 @@ internal fun layoutMonthWeeks(
|
|||||||
},
|
},
|
||||||
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every event touching each of [days], all-day first then by start time. Unlike
|
||||||
|
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
|
||||||
|
* they cover and applies no display cap, so the split style's day pane can list a
|
||||||
|
* date in full without querying the provider again.
|
||||||
|
*/
|
||||||
|
internal fun instancesByDay(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
instances: List<EventInstance>,
|
||||||
|
zone: TimeZone,
|
||||||
|
): Map<LocalDate, List<EventInstance>> =
|
||||||
|
days.associateWith { day ->
|
||||||
|
instances
|
||||||
|
.filter { it.coversDay(day, zone) }
|
||||||
|
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
* The on-screen grid spans 6 weeks anchored on [weekStart]. Includes the
|
||||||
* trailing days of the previous month and the leading days of the next month.
|
* trailing days of the previous month and the leading days of the next month.
|
||||||
@@ -200,6 +474,198 @@ internal fun monthGridRange(
|
|||||||
return start..end
|
return start..end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sliding window's shape.
|
||||||
|
*
|
||||||
|
* [INITIAL_PAD] is what the first frame waits for — one month either side of the
|
||||||
|
* visible one, so opening the view costs about what the paged style costs. Each
|
||||||
|
* completed load then reaches [GROWTH_STEP] further out until [MAX_PAD], filling
|
||||||
|
* in the months a scroll could reach while the first ones are already on screen.
|
||||||
|
*
|
||||||
|
* [WINDOW_EDGE] is how close the visible range may drift to a loaded edge before
|
||||||
|
* it reloads. It is always kept below the current pad — a trigger at or beyond
|
||||||
|
* the pad would re-fire the moment its own reload landed.
|
||||||
|
*/
|
||||||
|
private const val INITIAL_PAD = 1
|
||||||
|
private const val GROWTH_STEP = 2
|
||||||
|
private const val MAX_PAD = 5
|
||||||
|
private const val WINDOW_EDGE = 2
|
||||||
|
|
||||||
|
/** The reload trigger for a given pad, held strictly inside it. */
|
||||||
|
internal fun edgeForPad(pad: Int): Int = minOf(WINDOW_EDGE, pad - 1).coerceAtLeast(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which day the split style should select when the grid lands on [month]:
|
||||||
|
* [today] when the month holds it, otherwise the 1st. Pure so the rule can be
|
||||||
|
* tested without standing up a view model and a provider behind it.
|
||||||
|
*/
|
||||||
|
internal fun selectionForMonth(month: YearMonth, today: LocalDate): LocalDate =
|
||||||
|
if (YearMonth(today.year, today.month) == month) {
|
||||||
|
today
|
||||||
|
} else {
|
||||||
|
LocalDate(month.year, month.month, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window to load for a visible range, or null to keep the current one.
|
||||||
|
*
|
||||||
|
* Kept pure and separate from the view model so the hysteresis — the reason a
|
||||||
|
* scroll doesn't re-query the provider on every frame — is testable on its own.
|
||||||
|
*/
|
||||||
|
internal fun nextLoadWindow(
|
||||||
|
loaded: IntRange,
|
||||||
|
firstVisible: Int,
|
||||||
|
lastVisible: Int,
|
||||||
|
pad: Int = MAX_PAD,
|
||||||
|
): IntRange? {
|
||||||
|
val edge = edgeForPad(pad)
|
||||||
|
val comfortablyInside =
|
||||||
|
firstVisible - edge >= loaded.first && lastVisible + edge <= loaded.last
|
||||||
|
if (comfortablyInside) return null
|
||||||
|
return (firstVisible - pad)..(lastVisible + pad)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The continuous grid addresses months by an absolute index, so the list has one
|
||||||
|
* stable, gap-free coordinate space to scroll through and key its items by.
|
||||||
|
* Index 0 is January 1900; the list runs to [continuousMonthCount].
|
||||||
|
*
|
||||||
|
* Unlike the week indexing this replaced, the coordinate space doesn't depend on
|
||||||
|
* the week-start preference — changing it reflows the rows *inside* a month
|
||||||
|
* block but never moves the block, so a scroll position stays put.
|
||||||
|
*/
|
||||||
|
private const val MONTH_INDEX_EPOCH_YEAR = 1900
|
||||||
|
private const val MONTH_INDEX_END_YEAR = 2100
|
||||||
|
|
||||||
|
internal fun monthIndexOf(ym: YearMonth): Int =
|
||||||
|
(ym.year - MONTH_INDEX_EPOCH_YEAR) * 12 + ym.month.ordinal
|
||||||
|
|
||||||
|
internal fun yearMonthForIndex(index: Int): YearMonth = YearMonth(
|
||||||
|
MONTH_INDEX_EPOCH_YEAR + index / 12,
|
||||||
|
Month.entries[index % 12],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Total months the continuous grid scrolls through (1900 → 2100). */
|
||||||
|
internal fun continuousMonthCount(): Int =
|
||||||
|
(MONTH_INDEX_END_YEAR - MONTH_INDEX_EPOCH_YEAR + 1) * 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each month occupies two LazyColumn items — its sticky header and the block of
|
||||||
|
* week rows under it — so the two coordinate spaces differ by a factor of two.
|
||||||
|
* Scrolling to a month means scrolling to its header.
|
||||||
|
*/
|
||||||
|
internal fun itemIndexForMonth(monthIndex: Int): Int = monthIndex * 2
|
||||||
|
|
||||||
|
internal fun monthIndexForItem(itemIndex: Int): Int = itemIndex / 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold a load window inside the months that actually exist. The pad
|
||||||
|
* [nextLoadWindow] adds can otherwise push an index past either end of the
|
||||||
|
* 1900–2100 span, and [yearMonthForIndex] has no month to hand back for one.
|
||||||
|
*/
|
||||||
|
internal fun clampMonthWindow(window: IntRange): IntRange {
|
||||||
|
val last = continuousMonthCount() - 1
|
||||||
|
return window.first.coerceIn(0, last)..window.last.coerceIn(0, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun firstOfMonth(ym: YearMonth): LocalDate = LocalDate(ym.year, ym.month, 1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dense style addresses *weeks* by absolute index instead: it has no month
|
||||||
|
* blocks to key by, and a row's identity has to survive the months around it
|
||||||
|
* scrolling past. Index 0 is the first week of 1900 under the active week-start,
|
||||||
|
* so — like the month space — every index is non-negative and the LazyColumn's
|
||||||
|
* item indices and week indices are the same number.
|
||||||
|
*
|
||||||
|
* Unlike month indices these *do* shift with the week-start preference, which is
|
||||||
|
* why the list state is keyed on it.
|
||||||
|
*/
|
||||||
|
private val WEEK_INDEX_EPOCH = LocalDate(1900, 1, 1)
|
||||||
|
private val WEEK_INDEX_END = LocalDate(2100, 12, 31)
|
||||||
|
|
||||||
|
internal fun weekIndexOf(date: LocalDate, weekStart: DayOfWeek): Int {
|
||||||
|
val base = WEEK_INDEX_EPOCH.startOfGridWeek(weekStart)
|
||||||
|
return base.daysUntil(date.startOfGridWeek(weekStart)) / 7
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun weekStartForIndex(index: Int, weekStart: DayOfWeek): LocalDate =
|
||||||
|
WEEK_INDEX_EPOCH.startOfGridWeek(weekStart).plus(index * 7, DateTimeUnit.DAY)
|
||||||
|
|
||||||
|
/** Total weeks the Dense grid scrolls through (1900 → 2100). */
|
||||||
|
internal fun continuousWeekCount(weekStart: DayOfWeek): Int =
|
||||||
|
weekIndexOf(WEEK_INDEX_END, weekStart) + 1
|
||||||
|
|
||||||
|
/** Which month a Dense row belongs to, for reporting the visible range. */
|
||||||
|
internal fun monthIndexForWeek(weekIndex: Int, weekStart: DayOfWeek): Int {
|
||||||
|
// The row's midweek day: a boundary week is reported as whichever month owns
|
||||||
|
// most of it, the same rule the title uses.
|
||||||
|
val midweek = weekStartForIndex(weekIndex, weekStart).plus(3, DateTimeUnit.DAY)
|
||||||
|
return monthIndexOf(YearMonth(midweek.year, midweek.month))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every week index the months in [window] touch — the Dense rows one month-window
|
||||||
|
* load covers. Widened by a week at each end so the boundary rows either side are
|
||||||
|
* laid out too, rather than flickering as placeholders.
|
||||||
|
*/
|
||||||
|
internal fun weekWindowFor(window: IntRange, weekStart: DayOfWeek): IntRange {
|
||||||
|
val first = weekIndexOf(firstOfMonth(yearMonthForIndex(window.first)), weekStart) - 1
|
||||||
|
val lastMonthEnd = firstOfMonth(yearMonthForIndex(window.last))
|
||||||
|
.plus(1, DateTimeUnit.MONTH)
|
||||||
|
.minus(1, DateTimeUnit.DAY)
|
||||||
|
return first..(weekIndexOf(lastMonthEnd, weekStart) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many week rows [ym] needs under [weekStart] — 4 (a non-leap February), 5 or 6. */
|
||||||
|
internal fun weekRowsInMonth(ym: YearMonth, weekStart: DayOfWeek): Int {
|
||||||
|
val first = firstOfMonth(ym)
|
||||||
|
val leadOffset = ((first.dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
val daysInMonth = java.time.YearMonth.of(ym.year, ym.month.ordinal + 1).lengthOfMonth()
|
||||||
|
return (leadOffset + daysInMonth + 6) / 7
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip everything outside [month] from a boundary week row, for the continuous
|
||||||
|
* style's self-contained month blocks.
|
||||||
|
*
|
||||||
|
* The seven [MonthWeek.days] stay put — the row keeps its column geometry, and
|
||||||
|
* the grid decides which cells to draw blank — but the neighbour month's events
|
||||||
|
* are removed: its pills and counts go, and a bar reaching in from (or out into)
|
||||||
|
* it is cut back to the month's own columns. A bar cut this way keeps a flat
|
||||||
|
* cap, which is what says "this continues past the block" rather than "it ends
|
||||||
|
* here".
|
||||||
|
*/
|
||||||
|
internal fun clipWeekToMonth(week: MonthWeek, month: YearMonth): MonthWeek {
|
||||||
|
val inMonth = week.days.map { it.year == month.year && it.month == month.month }
|
||||||
|
val firstCol = inMonth.indexOfFirst { it }
|
||||||
|
val lastCol = inMonth.indexOfLast { it }
|
||||||
|
// Can't happen for a row of the month's own grid, but a caller-proof no-op
|
||||||
|
// beats an out-of-range crash.
|
||||||
|
if (firstCol == -1) {
|
||||||
|
return week.copy(spans = emptyList(), timedByDay = emptyMap(), countByDay = emptyMap())
|
||||||
|
}
|
||||||
|
val spans = week.spans.mapNotNull { span ->
|
||||||
|
val start = maxOf(span.startCol, firstCol)
|
||||||
|
val end = minOf(span.endCol, lastCol)
|
||||||
|
if (start > end) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
span.copy(
|
||||||
|
startCol = start,
|
||||||
|
endCol = end,
|
||||||
|
continuesLeft = span.continuesLeft || start > span.startCol,
|
||||||
|
continuesRight = span.continuesRight || end < span.endCol,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val own = week.days.filterIndexed { col, _ -> inMonth[col] }
|
||||||
|
return week.copy(
|
||||||
|
spans = spans,
|
||||||
|
timedByDay = week.timedByDay.filterKeys { it in own },
|
||||||
|
countByDay = week.countByDay.filterKeys { it in own },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
internal fun LocalDate.startOfGridWeek(weekStart: DayOfWeek): LocalDate {
|
||||||
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.
|
||||||
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
val offset = ((dayOfWeek.ordinal - weekStart.ordinal) + 7) % 7
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Month view lays itself out (#38, #53).
|
||||||
|
*
|
||||||
|
* One setting rather than two independent toggles: "vertical scrolling" and
|
||||||
|
* "month plus agenda" would otherwise multiply into four combinations to build
|
||||||
|
* and test, most of which nobody asked for.
|
||||||
|
*
|
||||||
|
* [Split] does **not** replace or disable the Agenda view — that stays a forward
|
||||||
|
* multi-day window with its own range model, while the split pane lists a single
|
||||||
|
* selected day.
|
||||||
|
*/
|
||||||
|
enum class MonthViewStyle {
|
||||||
|
/** Month pages, swiped left/right. Full event bars and pills per day. */
|
||||||
|
Paged,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Months stacked into one vertical scroll, each a self-contained block under
|
||||||
|
* its own sticky header: a block shows only its own days, and whitespace
|
||||||
|
* separates it from the next (#38).
|
||||||
|
*/
|
||||||
|
Continuous,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Continuous] with the seams taken out: one uninterrupted stream of weeks
|
||||||
|
* where months flow into each other. Each date appears exactly once — paging
|
||||||
|
* repeats a boundary week at both ends — and the 1st names its month, the
|
||||||
|
* only marker of where one ends.
|
||||||
|
*/
|
||||||
|
Dense,
|
||||||
|
|
||||||
|
/** Compact dots-only grid over a list of the selected day's events (#53). */
|
||||||
|
Split,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both vertically scrolling styles, which share a data window and a list state. */
|
||||||
|
val MonthViewStyle.isScrolling: Boolean
|
||||||
|
get() = this == MonthViewStyle.Continuous || this == MonthViewStyle.Dense
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val MonthViewStyle.labelRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
MonthViewStyle.Paged -> R.string.month_style_paged
|
||||||
|
MonthViewStyle.Continuous -> R.string.month_style_continuous
|
||||||
|
MonthViewStyle.Dense -> R.string.month_style_dense
|
||||||
|
MonthViewStyle.Split -> R.string.month_style_split
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val MonthViewStyle.descriptionRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
MonthViewStyle.Paged -> R.string.month_style_paged_summary
|
||||||
|
MonthViewStyle.Continuous -> R.string.month_style_continuous_summary
|
||||||
|
MonthViewStyle.Dense -> R.string.month_style_dense_summary
|
||||||
|
MonthViewStyle.Split -> R.string.month_style_split_summary
|
||||||
|
}
|
||||||
@@ -54,10 +54,11 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
|||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
|
||||||
import de.jeanlucmakiola.floret.components.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
|
||||||
@@ -192,6 +193,7 @@ private fun SearchResultRow(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
title = event.title,
|
title = event.title,
|
||||||
@@ -203,7 +205,7 @@ private fun SearchResultRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 6.dp, height = 36.dp)
|
.size(width = 6.dp, height = 36.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(pastelize(event.color, dark)),
|
.background(eventFill(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Month view style chooser (#38, #53).
|
||||||
|
*
|
||||||
|
* A live, scaled-down Month view sits above the options and changes as you pick
|
||||||
|
* one, so the choice is made by looking rather than by reading "Continuous" and
|
||||||
|
* guessing. Selecting therefore applies immediately and leaves the picker open —
|
||||||
|
* closing on tap would hide the very thing the screen is for. Back exits, the
|
||||||
|
* same as the App name picker, which stays open for the same reason.
|
||||||
|
*
|
||||||
|
* Under the preview sits the selected style's own one-line blurb, then the
|
||||||
|
* family's standard picker shape: connected single-line grouped rows with a
|
||||||
|
* tonal highlight and [SelectedCheck] on the current one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun MonthViewStylePicker(
|
||||||
|
selected: MonthViewStyle,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
onSelect: (MonthViewStyle) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val options = MonthViewStyle.entries
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_month_view_style),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
.height(PREVIEW_HEIGHT),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
targetState = selected,
|
||||||
|
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||||
|
label = "month-style-preview",
|
||||||
|
) { shown ->
|
||||||
|
// The preview renders the real grid at phone size and scales it
|
||||||
|
// down, so its own corners are square — the frame rounds it.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(PREVIEW_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
MonthStylePreview(
|
||||||
|
style = shown,
|
||||||
|
weekStart = weekStart,
|
||||||
|
height = PREVIEW_HEIGHT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The selected style's own blurb lives here, under its preview, rather
|
||||||
|
// than on every row — the rows stay single-line and dense, and the words
|
||||||
|
// describe exactly what is being shown above.
|
||||||
|
PickerDescription(stringResource(selected.descriptionRes))
|
||||||
|
options.forEachIndexed { index, style ->
|
||||||
|
val isSelected = style == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(style.labelRes),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
// Applies straight away; the preview above is the confirmation,
|
||||||
|
// so there is nothing to dismiss for.
|
||||||
|
onClick = { onSelect(style) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PREVIEW_HEIGHT = 280.dp
|
||||||
|
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||||
@@ -23,6 +23,8 @@ import androidx.compose.animation.slideInHorizontally
|
|||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -42,8 +44,8 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
|||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.Cake
|
import androidx.compose.material.icons.filled.Cake
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
import androidx.compose.material.icons.filled.DragHandle
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
import androidx.compose.material.icons.filled.SwapVert
|
import androidx.compose.material.icons.filled.SwapVert
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
@@ -75,6 +77,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
@@ -93,6 +96,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
@@ -100,6 +104,7 @@ import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
@@ -121,6 +126,7 @@ import de.jeanlucmakiola.floret.components.GroupedRow
|
|||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
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.month.labelRes
|
||||||
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
||||||
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
@@ -131,10 +137,12 @@ 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.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.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.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||||
@@ -486,8 +494,10 @@ private fun AppearanceScreen(
|
|||||||
var showPastEvents by remember { mutableStateOf(false) }
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
var showBrandFont by remember { mutableStateOf(false) }
|
var showBrandFont by remember { mutableStateOf(false) }
|
||||||
var showPlainFont by remember { mutableStateOf(false) }
|
var showPlainFont by remember { mutableStateOf(false) }
|
||||||
|
var showAppName by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||||
|
val launcherName by viewModel.launcherName.collectAsStateWithLifecycle()
|
||||||
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||||
@@ -516,7 +526,7 @@ private fun AppearanceScreen(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.settings_dynamic_color_unavailable)
|
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||||
},
|
},
|
||||||
position = Position.Bottom,
|
position = Position.Middle,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.dynamicColor,
|
checked = state.dynamicColor,
|
||||||
@@ -530,6 +540,18 @@ private fun AppearanceScreen(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_soften_colors),
|
||||||
|
summary = stringResource(R.string.settings_soften_colors_summary),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.softenColors,
|
||||||
|
onCheckedChange = viewModel::setSoftenColors,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setSoftenColors(!state.softenColors) },
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -558,6 +580,18 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showDefaultView = true },
|
onClick = { showDefaultView = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_today_toolbar),
|
||||||
|
summary = stringResource(R.string.settings_today_toolbar_summary),
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.todayButtonInToolbar,
|
||||||
|
onCheckedChange = viewModel::setTodayButtonInToolbar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setTodayButtonInToolbar(!state.todayButtonInToolbar) },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
summary = weekStartLabel(state.weekStart),
|
summary = weekStartLabel(state.weekStart),
|
||||||
@@ -630,6 +664,18 @@ private fun AppearanceScreen(
|
|||||||
position = Position.Middle,
|
position = Position.Middle,
|
||||||
onClick = { showAgendaWidgetRange = true },
|
onClick = { showAgendaWidgetRange = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_show_today),
|
||||||
|
summary = stringResource(R.string.settings_agenda_show_today_hint),
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.agendaShowToday,
|
||||||
|
onCheckedChange = viewModel::setAgendaShowToday,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAgendaShowToday(!state.agendaShowToday) },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_agenda_range_bar),
|
title = stringResource(R.string.settings_agenda_range_bar),
|
||||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||||
@@ -642,8 +688,58 @@ private fun AppearanceScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// App name — chooses the launcher label between "Calendula" and "Calendar"
|
||||||
|
// (issue #44). Own group: it's a launcher/system concern, not calendar
|
||||||
|
// formatting. A sub-page chooser (not a switch), matching the app's other
|
||||||
|
// "choose one" settings and leaving room for more names later.
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
summary = launcherNameLabel(launcherName),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showAppName = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showAppName) {
|
||||||
|
FullScreenPicker(
|
||||||
|
title = stringResource(R.string.settings_app_name),
|
||||||
|
onDismiss = { showAppName = false },
|
||||||
|
predictiveBack = true,
|
||||||
|
) {
|
||||||
|
// Show both names as launcher-mark previews so the user sees what
|
||||||
|
// they'd switch to, not just the current state. Tapping applies
|
||||||
|
// immediately and highlights — the picker stays open so the change is
|
||||||
|
// visible; back exits.
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.settings_app_name_summary),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
LauncherName.entries.forEach { option ->
|
||||||
|
AppNameOptionCard(
|
||||||
|
name = option,
|
||||||
|
selected = launcherName == option,
|
||||||
|
onClick = { viewModel.setLauncherName(option) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
@@ -753,12 +849,24 @@ private fun ViewsScreen(
|
|||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var showMonthStyle by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_views),
|
title = stringResource(R.string.settings_section_views),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
) {
|
) {
|
||||||
val config = state.quickSwitchConfig
|
val config = state.quickSwitchConfig
|
||||||
|
|
||||||
|
// Per-view layout, above the cross-view switcher/order settings below.
|
||||||
|
SectionHeader(stringResource(R.string.settings_month_header))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_month_view_style),
|
||||||
|
summary = stringResource(state.monthViewStyle.labelRes),
|
||||||
|
position = Position.Alone,
|
||||||
|
onClick = { showMonthStyle = true },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -804,6 +912,16 @@ private fun ViewsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showMonthStyle) {
|
||||||
|
MonthViewStylePicker(
|
||||||
|
selected = state.monthViewStyle,
|
||||||
|
// The preview is a real grid, so it uses the real week start too.
|
||||||
|
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||||
|
onSelect = viewModel::setMonthViewStyle,
|
||||||
|
onDismiss = { showMonthStyle = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
||||||
@@ -1056,13 +1174,7 @@ private fun NotificationsScreen(
|
|||||||
},
|
},
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
trailing = if (batteryExempt) {
|
trailing = if (batteryExempt) {
|
||||||
{
|
{ SelectedCheck() }
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
@@ -1174,10 +1286,9 @@ private fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showSnooze) {
|
if (showSnooze) {
|
||||||
OptionPicker(
|
SnoozeDurationPicker(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
predictiveBack = true,
|
presets = SNOOZE_PRESETS,
|
||||||
options = SNOOZE_PRESETS,
|
|
||||||
selected = state.snoozeMinutes,
|
selected = state.snoozeMinutes,
|
||||||
label = { snoozeDurationLabel(it) },
|
label = { snoozeDurationLabel(it) },
|
||||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||||
@@ -1668,6 +1779,98 @@ private fun openUrl(context: Context, url: String) {
|
|||||||
runCatching { context.startActivity(intent) }
|
runCatching { context.startActivity(intent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The display name for a launcher-label choice (issue #44). */
|
||||||
|
@Composable
|
||||||
|
private fun launcherNameLabel(name: LauncherName): String = stringResource(
|
||||||
|
when (name) {
|
||||||
|
LauncherName.CALENDULA -> R.string.app_name
|
||||||
|
LauncherName.CALENDAR -> R.string.app_name_calendar_alias
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One selectable launcher-name preview in the App name picker (issue #44): the
|
||||||
|
* app's launcher mark over the name, framed as a card. The active one carries a
|
||||||
|
* primary border, a tinted container and a check; tapping selects it. The mark
|
||||||
|
* is the same for both — only the label changes — so the card previews exactly
|
||||||
|
* what the home screen will read.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AppNameOptionCard(
|
||||||
|
name: LauncherName,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
val borderColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.outlineVariant
|
||||||
|
}
|
||||||
|
val containerColor = if (selected) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(containerColor)
|
||||||
|
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 20.dp, horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// The adaptive launcher mark, reconstructed as a squircle (as in the
|
||||||
|
// onboarding BrandHero) so it renders identically everywhere.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = launcherNameLabel(name),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
// Selection indicator: a filled check when active, an empty ring otherwise.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||||
|
.then(
|
||||||
|
if (selected) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (selected) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||||
when (mode) {
|
when (mode) {
|
||||||
@@ -1809,13 +2012,7 @@ private fun FontOptionRow(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailing = if (selected) {
|
trailing = if (selected) {
|
||||||
{
|
{ SelectedCheck() }
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -24,6 +25,8 @@ data class SettingsUiState(
|
|||||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
val dynamicColorAvailable: Boolean = true,
|
val dynamicColorAvailable: Boolean = true,
|
||||||
|
/** Whether raw provider colours are softened to theme-fitting pastels (#36). */
|
||||||
|
val softenColors: Boolean = true,
|
||||||
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||||
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||||
@@ -35,16 +38,22 @@ data class SettingsUiState(
|
|||||||
val dimCompletedEvents: Boolean = false,
|
val dimCompletedEvents: Boolean = false,
|
||||||
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
||||||
val showWeekNumbers: Boolean = false,
|
val showWeekNumbers: Boolean = false,
|
||||||
|
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
||||||
|
val todayButtonInToolbar: Boolean = false,
|
||||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||||
|
/** Whether the agenda (screen + widget) always anchors today at the top (#35). */
|
||||||
|
val agendaShowToday: Boolean = true,
|
||||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||||
val agendaShowRangeBar: Boolean = true,
|
val agendaShowRangeBar: Boolean = true,
|
||||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||||
|
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
||||||
|
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherName
|
||||||
|
import de.jeanlucmakiola.calendula.data.appname.LauncherNameManager
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
@@ -33,16 +35,20 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_SHOW_TODAY_STATE_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -61,6 +67,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
private val specialDatesEngine: SpecialDatesSyncEngine,
|
private val specialDatesEngine: SpecialDatesSyncEngine,
|
||||||
specialDatesSpec: SpecialDatesCalendarSpec,
|
specialDatesSpec: SpecialDatesCalendarSpec,
|
||||||
|
private val launcherNameManager: LauncherNameManager,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -116,14 +123,25 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.agendaScreenRange,
|
prefs.agendaScreenRange,
|
||||||
prefs.agendaWidgetRange,
|
prefs.agendaWidgetRange,
|
||||||
prefs.timeFormat,
|
prefs.timeFormat,
|
||||||
// Two grid-display toggles folded into one flow so they fit this
|
// Display toggles folded into one flow so they fit this group —
|
||||||
// group — the outer combine is already at its five-arg limit.
|
// the outer combine is already at its five-arg limit.
|
||||||
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
|
combine(
|
||||||
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
|
prefs.showHourLines,
|
||||||
|
prefs.showWeekNumbers,
|
||||||
|
prefs.agendaShowToday,
|
||||||
|
prefs.softenCalendarColors,
|
||||||
|
prefs.todayButtonInToolbar,
|
||||||
|
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
|
||||||
|
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
|
||||||
|
},
|
||||||
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
ViewSettings(
|
ViewSettings(
|
||||||
view, screenRange, widgetRange, timeFormat,
|
view, screenRange, widgetRange, timeFormat,
|
||||||
showHourLines = gridToggles.first,
|
showHourLines = toggles.showHourLines,
|
||||||
showWeekNumbers = gridToggles.second,
|
showWeekNumbers = toggles.showWeekNumbers,
|
||||||
|
agendaShowToday = toggles.agendaShowToday,
|
||||||
|
softenColors = toggles.softenColors,
|
||||||
|
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
@@ -133,8 +151,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.dimCompletedEvents,
|
prefs.dimCompletedEvents,
|
||||||
// View customisation (#24) folded into one flow so it fits this
|
// View customisation (#24) folded into one flow so it fits this
|
||||||
// group — the outer combine is already at its five-arg limit.
|
// group — the outer combine is already at its five-arg limit.
|
||||||
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
|
combine(
|
||||||
ViewCustomization(quickSwitch, drawer)
|
prefs.quickSwitchConfig,
|
||||||
|
prefs.drawerViewOrder,
|
||||||
|
prefs.monthViewStyle,
|
||||||
|
) { quickSwitch, drawer, monthStyle ->
|
||||||
|
ViewCustomization(quickSwitch, drawer, monthStyle)
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -147,12 +169,16 @@ class SettingsViewModel @Inject constructor(
|
|||||||
timeFormat = views.timeFormat,
|
timeFormat = views.timeFormat,
|
||||||
showHourLines = views.showHourLines,
|
showHourLines = views.showHourLines,
|
||||||
showWeekNumbers = views.showWeekNumbers,
|
showWeekNumbers = views.showWeekNumbers,
|
||||||
|
agendaShowToday = views.agendaShowToday,
|
||||||
|
softenColors = views.softenColors,
|
||||||
|
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
dimCompletedEvents = misc.dimCompletedEvents,
|
dimCompletedEvents = misc.dimCompletedEvents,
|
||||||
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -187,6 +213,28 @@ class SettingsViewModel @Inject constructor(
|
|||||||
initialValue = AppFontSettings(),
|
initialValue = AppFontSettings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher-label choice (issue #44). Backed by the manifest aliases'
|
||||||
|
* component-enabled state rather than a stored preference, so it's read
|
||||||
|
* imperatively via [LauncherNameManager] and held here in its own flow — the
|
||||||
|
* main settings combine is already at its arity limit and this isn't a
|
||||||
|
* DataStore flow anyway.
|
||||||
|
*
|
||||||
|
* Seeded with the manifest default and corrected off-thread rather than read
|
||||||
|
* in the initializer: `getComponentEnabledSetting` is a binder round-trip to
|
||||||
|
* system_server, and this ViewModel is constructed on the main thread while
|
||||||
|
* Settings is opening. The row it feeds is well below the fold, so the
|
||||||
|
* one-frame correction is never visible.
|
||||||
|
*/
|
||||||
|
private val _launcherName = MutableStateFlow(LauncherName.CALENDULA)
|
||||||
|
val launcherName: StateFlow<LauncherName> = _launcherName.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_launcherName.value = withContext(io) { launcherNameManager.current() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Emitted when a picked font file couldn't be read as a font; the screen
|
// Emitted when a picked font file couldn't be read as a font; the screen
|
||||||
// surfaces it and the previous selection stays put.
|
// surfaces it and the previous selection stays put.
|
||||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
@@ -220,6 +268,17 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val timeFormat: TimeFormatPref,
|
val timeFormat: TimeFormatPref,
|
||||||
val showHourLines: Boolean,
|
val showHourLines: Boolean,
|
||||||
val showWeekNumbers: Boolean,
|
val showWeekNumbers: Boolean,
|
||||||
|
val agendaShowToday: Boolean,
|
||||||
|
val softenColors: Boolean,
|
||||||
|
val todayButtonInToolbar: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class DisplayToggles(
|
||||||
|
val showHourLines: Boolean,
|
||||||
|
val showWeekNumbers: Boolean,
|
||||||
|
val agendaShowToday: Boolean,
|
||||||
|
val softenColors: Boolean,
|
||||||
|
val todayButtonInToolbar: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -233,6 +292,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private data class ViewCustomization(
|
private data class ViewCustomization(
|
||||||
val quickSwitch: QuickSwitchConfig,
|
val quickSwitch: QuickSwitchConfig,
|
||||||
val drawerOrder: List<CalendarView>,
|
val drawerOrder: List<CalendarView>,
|
||||||
|
val monthViewStyle: MonthViewStyle,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||||
@@ -332,6 +392,19 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSoftenColors(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setSoftenCalendarColors(enabled)
|
||||||
|
// Both widgets paint event colours through the same softener, so a
|
||||||
|
// change has to redraw them too (they read the flag in their data
|
||||||
|
// preamble, like is24Hour).
|
||||||
|
widgetRefreshMutex.withLock {
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
MonthWidget().updateAll(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setFont(role: FontRole, token: String) {
|
fun setFont(role: FontRole, token: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (role) {
|
when (role) {
|
||||||
@@ -398,6 +471,22 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAgendaShowToday(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setAgendaShowToday(enabled)
|
||||||
|
// The agenda widget reads this reactively, so push it into each
|
||||||
|
// instance's Glance state and recompose — same reliable-update path as
|
||||||
|
// setPastEventDisplay (updateAll alone won't re-run the data preamble).
|
||||||
|
widgetRefreshMutex.withLock {
|
||||||
|
val manager = GlanceAppWidgetManager(appContext)
|
||||||
|
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||||
|
updateAppWidgetState(appContext, id) { it[AGENDA_SHOW_TODAY_STATE_KEY] = enabled }
|
||||||
|
}
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setTimeFormat(pref: TimeFormatPref) {
|
fun setTimeFormat(pref: TimeFormatPref) {
|
||||||
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||||
}
|
}
|
||||||
@@ -410,6 +499,26 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
|
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTodayButtonInToolbar(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch the launcher label between "Calendula" and "Calendar" (issue #44).
|
||||||
|
* The card highlights immediately, then settles on whatever the component
|
||||||
|
* state actually reports — the two `setComponentEnabledSetting` calls are
|
||||||
|
* binder round-trips, so they don't belong on the main thread either.
|
||||||
|
*/
|
||||||
|
fun setLauncherName(name: LauncherName) {
|
||||||
|
_launcherName.value = name
|
||||||
|
viewModelScope.launch {
|
||||||
|
_launcherName.value = withContext(io) {
|
||||||
|
launcherNameManager.set(name)
|
||||||
|
launcherNameManager.current()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.setPastEventDisplay(mode)
|
prefs.setPastEventDisplay(mode)
|
||||||
@@ -460,6 +569,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMonthViewStyle(style: MonthViewStyle) {
|
||||||
|
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import androidx.compose.animation.animateColorAsState
|
|||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -49,7 +48,6 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -77,20 +75,26 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
@@ -98,7 +102,6 @@ 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.floret.components.pastelize
|
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -114,6 +117,10 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
private val HOUR_HEIGHT = 56.dp
|
||||||
private val GUTTER_WIDTH = 48.dp
|
private val GUTTER_WIDTH = 48.dp
|
||||||
|
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||||
|
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
||||||
|
* (the app bar's 4dp inset + 24dp half icon button). */
|
||||||
|
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
private val MIN_EVENT_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
@@ -137,6 +144,7 @@ fun WeekScreen(
|
|||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
todayInToolbar: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -174,6 +182,13 @@ fun WeekScreen(
|
|||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drives whether the title carries the year. Falls back to the clock only
|
||||||
|
// while the first load is in flight, when there is no state to read today from.
|
||||||
|
val currentYear = when (val s = state) {
|
||||||
|
is WeekUiState.Success -> s.today.year
|
||||||
|
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
|
||||||
|
}
|
||||||
|
|
||||||
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
// Slide direction for the week transition: +1 = next, -1 = prev, 0 = jump.
|
||||||
var slideDir by remember { mutableIntStateOf(0) }
|
var slideDir by remember { mutableIntStateOf(0) }
|
||||||
val goNext = { slideDir = 1; viewModel.goToNext() }
|
val goNext = { slideDir = 1; viewModel.goToNext() }
|
||||||
@@ -222,16 +237,19 @@ fun WeekScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
showTodayButton = todayInToolbar,
|
||||||
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
CalendarFabColumn(
|
CalendarFabColumn(
|
||||||
todayVisible = !isOnCurrentWeek,
|
todayVisible = !isOnCurrentWeek && !todayInToolbar,
|
||||||
todayText = stringResource(R.string.week_today_action),
|
todayText = stringResource(R.string.week_today_action),
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
onCreate = {
|
onCreate = {
|
||||||
@@ -277,8 +295,6 @@ private fun WeekContent(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val threshold = with(density) { 24.dp.toPx() }
|
|
||||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -310,20 +326,7 @@ private fun WeekContent(
|
|||||||
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
// vertical scroll: a horizontal drag only crosses *this* detector's slop,
|
||||||
// while a vertical drag is consumed by the inner scroll first — so the two
|
// while a vertical drag is consumed by the inner scroll first — so the two
|
||||||
// gestures coexist without fighting.
|
// gestures coexist without fighting.
|
||||||
val swipeModifier = Modifier.pointerInput(Unit) {
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragStart = { dragAccum = 0f },
|
|
||||||
onDragEnd = {
|
|
||||||
when {
|
|
||||||
dragAccum < -threshold -> onSwipeNext()
|
|
||||||
dragAccum > threshold -> onSwipePrev()
|
|
||||||
}
|
|
||||||
dragAccum = 0f
|
|
||||||
},
|
|
||||||
onDragCancel = { dragAccum = 0f },
|
|
||||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state,
|
targetState = state,
|
||||||
@@ -389,16 +392,20 @@ private fun WeekSuccess(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun WeekTopBar(
|
private fun WeekTopBar(
|
||||||
weekStart: LocalDate,
|
weekStart: LocalDate,
|
||||||
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
showTodayButton: Boolean,
|
||||||
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = formatWeekRange(weekStart),
|
text = formatWeekTitle(weekStart, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -411,6 +418,7 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
TodayAction(show = showTodayButton, onToday = onToday)
|
||||||
IconButton(onClick = onOpenSearch) {
|
IconButton(onClick = onOpenSearch) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = Icons.Default.Search,
|
||||||
@@ -448,9 +456,10 @@ private fun WeekDayHeader(
|
|||||||
.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)
|
||||||
@@ -584,9 +593,11 @@ private fun AllDayBar(
|
|||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
@@ -597,7 +608,7 @@ private fun AllDayBar(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.8f),
|
color = eventInk(fill),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -621,10 +632,12 @@ private fun Timeline(
|
|||||||
// soft corners are permanent at any scroll position (not just at the
|
// soft corners are permanent at any scroll position (not just at the
|
||||||
// day's start/end).
|
// day's start/end).
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
// Hour gutter (scrolls in sync with the day columns)
|
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||||
|
// as the header badge so the labels sit under it and on the hamburger.
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(GUTTER_WIDTH)
|
.width(GUTTER_WIDTH)
|
||||||
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
) {
|
) {
|
||||||
@@ -774,9 +787,11 @@ private fun EventBlock(
|
|||||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics { contentDescription = "$title, $timeLabel" },
|
||||||
@@ -787,7 +802,7 @@ private fun EventBlock(
|
|||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = titleMaxLines,
|
maxLines = titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
)
|
)
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
Text(
|
Text(
|
||||||
@@ -795,7 +810,7 @@ private fun EventBlock(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.6f),
|
color = eventInk(fill, alpha = 0.6f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -841,18 +856,29 @@ private fun WeekLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
/**
|
||||||
val locale = Locale.getDefault()
|
* The week's title: just the month [weekStart] falls in.
|
||||||
val end = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
*
|
||||||
val monthName = { d: LocalDate ->
|
* The day numbers are already in the column headers directly below, so spelling
|
||||||
java.time.Month.of(d.month.ordinal + 1).getDisplayName(JavaTextStyle.SHORT, locale)
|
* out "13.–19. Jul" restates them in the widest string in the bar. Naming the
|
||||||
}
|
* month of [weekStart] means a week straddling a boundary keeps the outgoing
|
||||||
return if (weekStart.month == end.month && weekStart.year == end.year) {
|
* month until it is fully gone — a week is seven contiguous days, so the earlier
|
||||||
"${weekStart.day}.–${end.day}. ${monthName(weekStart)} ${weekStart.year}"
|
* month has a day in it exactly while [weekStart] is still inside it. That also
|
||||||
} else if (weekStart.year == end.year) {
|
* makes the *month* depend on nothing but [weekStart], so it cannot drift with
|
||||||
"${weekStart.day}. ${monthName(weekStart)} – ${end.day}. ${monthName(end)} ${end.year}"
|
* the direction you paged in from.
|
||||||
} else {
|
*
|
||||||
"${weekStart.day}. ${monthName(weekStart)} ${weekStart.year} – " +
|
* The *year* is decided from the whole week, not just its start: a week running
|
||||||
"${end.day}. ${monthName(end)} ${end.year}"
|
* Dec 28 – Jan 3 has four of its seven visible columns in the new year, so
|
||||||
}
|
* "December" with no year would be actively misleading while the reader is
|
||||||
|
* looking straight at January dates.
|
||||||
|
*/
|
||||||
|
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
|
||||||
|
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||||
|
return formatCalendarTitle(
|
||||||
|
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||||
|
locale = locale,
|
||||||
|
currentYear = currentYear,
|
||||||
|
skeleton = "LLLL",
|
||||||
|
forceYear = weekEnd.year != weekStart.year,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,12 +60,20 @@ sealed interface AgendaWidgetData {
|
|||||||
val days: List<AgendaDay>,
|
val days: List<AgendaDay>,
|
||||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||||
val is24Hour: Boolean,
|
val is24Hour: Boolean,
|
||||||
|
/** Whether event colours are softened to pastels, or shown raw (#36). */
|
||||||
|
val soften: Boolean,
|
||||||
/** First day of the week, for the calendar-aligned "this week" range. */
|
/** First day of the week, for the calendar-aligned "this week" range. */
|
||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||||
val savedRange: AgendaRange,
|
val savedRange: AgendaRange,
|
||||||
/** Saved past-event display pref — the fallback before Glance state is set. */
|
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||||
val savedPastDisplay: PastEventDisplay,
|
val savedPastDisplay: PastEventDisplay,
|
||||||
|
/**
|
||||||
|
* Saved "always show today" pref (#35) — anchors today at the top with a
|
||||||
|
* placeholder even when it has no events left. The fallback read reactively
|
||||||
|
* from Glance state before an instance has its own state set.
|
||||||
|
*/
|
||||||
|
val savedShowToday: Boolean,
|
||||||
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||||
val now: Instant,
|
val now: Instant,
|
||||||
) : AgendaWidgetData
|
) : AgendaWidgetData
|
||||||
@@ -117,21 +125,26 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
val prefs = ep.settingsPrefs()
|
val prefs = ep.settingsPrefs()
|
||||||
val savedRange = prefs.agendaWidgetRange.first()
|
val savedRange = prefs.agendaWidgetRange.first()
|
||||||
val savedPastDisplay = prefs.pastEventDisplay.first()
|
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||||
|
val showToday = prefs.agendaShowToday.first()
|
||||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
// Load the widest selectable window once; the displayed range is sliced in
|
// Load the widest selectable window once; the displayed range is sliced in
|
||||||
// the composition from Glance state, so changing the range is a plain
|
// the composition from Glance state, so changing the range is a plain
|
||||||
// recomposition and never depends on the widget session restarting.
|
// recomposition and never depends on the widget session restarting.
|
||||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
||||||
|
val windowEnd = anchor.plus(AgendaRange.MAX_CUSTOM_DAYS - 1, DateTimeUnit.DAY)
|
||||||
val instances = ep.calendarRepository().instances(window).first()
|
val instances = ep.calendarRepository().instances(window).first()
|
||||||
val is24Hour = prefs.timeFormat.first()
|
val is24Hour = prefs.timeFormat.first()
|
||||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||||
|
val soften = prefs.softenCalendarColors.first()
|
||||||
return AgendaWidgetData.Ready(
|
return AgendaWidgetData.Ready(
|
||||||
today = anchor,
|
today = anchor,
|
||||||
days = groupAgendaDays(anchor, instances, zone),
|
days = groupAgendaDays(anchor, windowEnd, instances, zone),
|
||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
|
soften = soften,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
savedRange = savedRange,
|
savedRange = savedRange,
|
||||||
savedPastDisplay = savedPastDisplay,
|
savedPastDisplay = savedPastDisplay,
|
||||||
|
savedShowToday = showToday,
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Size tiers a widget scales its typography and metrics across (#51).
|
||||||
|
*
|
||||||
|
* Shared by every Glance widget so the bucketing rule can't drift between them:
|
||||||
|
* each widget keeps its own metrics table, but they all agree on *when* a widget
|
||||||
|
* counts as compact, regular, large or extra-large. Both widgets already declare
|
||||||
|
* [androidx.glance.appwidget.SizeMode.Exact], so the composition sees the live
|
||||||
|
* size via `LocalSize.current` and passes it to [scaleFor].
|
||||||
|
*
|
||||||
|
* Kept in a pure, Glance-free file (only `compose.ui.unit`) so the bucketing is
|
||||||
|
* covered by plain JVM tests.
|
||||||
|
*/
|
||||||
|
internal enum class WidgetScale { COMPACT, REGULAR, LARGE, XLARGE }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome a widget spends before its first content row: outer vertical padding
|
||||||
|
* plus a header row and its spacer. Subtracted from the raw height so the height
|
||||||
|
* thresholds below talk about *usable* space rather than gross widget height.
|
||||||
|
*/
|
||||||
|
private val CHROME_HEIGHT = 60.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buckets a live widget size into a [WidgetScale] by **width**.
|
||||||
|
*
|
||||||
|
* Width is the right axis: it governs how much of a title fits on a row, so it's
|
||||||
|
* what should drive type size. Height only decides how many rows are visible — a
|
||||||
|
* tall, narrow widget wants *more events*, not bigger text — so it never raises
|
||||||
|
* the tier. It does act as a **cap**, though: a genuinely squashed widget is
|
||||||
|
* stepped back down so it can't keep oversized type in a sliver of space.
|
||||||
|
*
|
||||||
|
* The width thresholds are spread across the range a phone can actually produce
|
||||||
|
* (~180dp up to roughly the screen width) rather than over a theoretical range,
|
||||||
|
* so the tiers are reachable in practice. Calibrated on-device (Pixel / Nova): a
|
||||||
|
* compact 222dp-wide widget stays COMPACT (the app's baseline, unchanged) and a
|
||||||
|
* full-width 378dp one reaches LARGE. XLARGE is reserved for genuinely wide
|
||||||
|
* surfaces — tablets, foldables, landscape — where the extra size reads well.
|
||||||
|
*
|
||||||
|
* The height cap is deliberately generous: it exists to catch a widget squashed
|
||||||
|
* to one or two rows, **not** to gate ordinary placements. A full-width widget at
|
||||||
|
* the usual three cells tall (~270dp) must still reach the tier its width earned
|
||||||
|
* — that is exactly the resize #51 reports, and an aggressive cap would make the
|
||||||
|
* whole feature a no-op for it.
|
||||||
|
*/
|
||||||
|
internal fun scaleFor(size: DpSize): WidgetScale {
|
||||||
|
val byWidth = when {
|
||||||
|
size.width < 260.dp -> WidgetScale.COMPACT
|
||||||
|
size.width < 330.dp -> WidgetScale.REGULAR
|
||||||
|
size.width < 420.dp -> WidgetScale.LARGE
|
||||||
|
else -> WidgetScale.XLARGE
|
||||||
|
}
|
||||||
|
// Height can only ever pull the tier *down*, never push it up: a squashed
|
||||||
|
// widget would otherwise keep the big type its width earned and look absurd
|
||||||
|
// in the little space left. Keeping this a cap (rather than a second scaling
|
||||||
|
// axis) is what preserves "tall and narrow shows more events, not bigger
|
||||||
|
// text". Thresholds are usable height — roughly one, two and three rows of
|
||||||
|
// breathing room once the header is paid for.
|
||||||
|
val usable = size.height - CHROME_HEIGHT
|
||||||
|
val heightCap = when {
|
||||||
|
usable < 70.dp -> WidgetScale.COMPACT
|
||||||
|
usable < 130.dp -> WidgetScale.REGULAR
|
||||||
|
usable < 200.dp -> WidgetScale.LARGE
|
||||||
|
else -> WidgetScale.XLARGE
|
||||||
|
}
|
||||||
|
return minOf(byWidth, heightCap)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal layout constants for an agenda event row. These don't scale with the
|
||||||
|
* tier — a wider stripe or gap would eat title width, which is the thing the row
|
||||||
|
* is short of — but [TEXT_INDENT] is *derived* from them so the day header and
|
||||||
|
* the "nothing left today" line can never drift out of alignment with the event
|
||||||
|
* title column the way a hardcoded 19dp could.
|
||||||
|
*/
|
||||||
|
internal val ROW_H_PAD = 4.dp
|
||||||
|
internal val STRIPE_WIDTH = 5.dp
|
||||||
|
internal val STRIPE_GAP = 10.dp
|
||||||
|
|
||||||
|
/** Left indent that lines non-event text up with the event title column. */
|
||||||
|
internal val TEXT_INDENT = ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The width band a *default* agenda placement can land in, per
|
||||||
|
* `app/src/main/res/xml/appwidget_info_agenda.xml` (`android:minWidth="180dp"`,
|
||||||
|
* `android:targetCellWidth="3"`). Measured at 222dp on a Pixel running Nova, but
|
||||||
|
* launcher cell grids vary, so the whole band — not one measured point — has to
|
||||||
|
* stay [WidgetScale.COMPACT] for the "default size is unchanged" promise of #51
|
||||||
|
* to hold. A test pins that.
|
||||||
|
*
|
||||||
|
* If the provider's `targetCellWidth` ever changes, this band and the first
|
||||||
|
* width threshold in [de.jeanlucmakiola.calendula.widget.scaleFor] must be
|
||||||
|
* revisited together.
|
||||||
|
*/
|
||||||
|
internal val AGENDA_DEFAULT_WIDTH_BAND = 180.dp..255.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every size the agenda widget varies by tier. Values that genuinely shouldn't
|
||||||
|
* grow (the horizontal row constants above, corner radii) stay constants rather
|
||||||
|
* than routing through here.
|
||||||
|
*/
|
||||||
|
internal data class AgendaMetrics(
|
||||||
|
val title: TextUnit, // header "Upcoming"
|
||||||
|
val dayHeader: TextUnit, // day label ("Today · …")
|
||||||
|
val eventTitle: TextUnit, // event title line
|
||||||
|
val eventTime: TextUnit, // time/location line
|
||||||
|
val placeholder: TextUnit, // "no more events today" (#35)
|
||||||
|
val message: TextUnit, // empty/permission centred message
|
||||||
|
val stripeH: Dp, // coloured event stripe height
|
||||||
|
val iconImage: Dp, // header action icon glyph
|
||||||
|
val iconBox: Dp, // header action touch target
|
||||||
|
val rowVPad: Dp, // event row vertical padding
|
||||||
|
val dayHeaderTopPad: Dp, // space above a day header
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Resolves the stripe height against the user's system font scale.
|
||||||
|
*
|
||||||
|
* The stripe is a [Dp] but the two text lines it sits beside are `sp`, so
|
||||||
|
* they scale with the accessibility font setting and the stripe does not —
|
||||||
|
* at "Largest" the text outgrows the stripe and it visibly under-runs the row
|
||||||
|
* it is supposed to mark. Multiplying by the same factor keeps them locked,
|
||||||
|
* and at the default scale of 1.0 reproduces the tier's value exactly.
|
||||||
|
*/
|
||||||
|
fun scaledForFont(fontScale: Float): AgendaMetrics =
|
||||||
|
if (fontScale == 1f) this else copy(stripeH = stripeH * fontScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Type sizes are anchored to the Material 3 type scale (see the `material-3`
|
||||||
|
* skill's typography reference) rather than invented: 16sp is Title Medium, 14sp
|
||||||
|
* Body Medium / Title Small, 12sp Body Small, 16sp Body Large, 22sp Title Large.
|
||||||
|
*
|
||||||
|
* Two documented deviations:
|
||||||
|
*
|
||||||
|
* ‡ COMPACT's 13sp day header is off-scale. It is held there deliberately —
|
||||||
|
* COMPACT reproduces the widget's original constants verbatim so a
|
||||||
|
* default-sized widget looks exactly as it did (#51), and snapping it to
|
||||||
|
* Title Small (14sp) would break that promise for a 1sp gain.
|
||||||
|
*
|
||||||
|
* † Above Title Medium the M3 scale jumps 16 → 22 → 24 with nothing in between,
|
||||||
|
* which is far too coarse for four widget tiers. Where a role would force a
|
||||||
|
* ≥1.4x step between adjacent tiers we hold an interpolated value instead and
|
||||||
|
* mark it. The endpoints stay on real roles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private val COMPACT_METRICS = AgendaMetrics(
|
||||||
|
title = 16.sp, // M3 Title Medium
|
||||||
|
dayHeader = 13.sp, // ‡ off-scale, holds the #51 baseline
|
||||||
|
eventTitle = 14.sp, // M3 Body Medium
|
||||||
|
eventTime = 12.sp, // M3 Body Small
|
||||||
|
placeholder = 14.sp, // M3 Body Medium
|
||||||
|
message = 14.sp, // M3 Body Medium
|
||||||
|
stripeH = 36.dp,
|
||||||
|
iconImage = 22.dp,
|
||||||
|
iconBox = 40.dp,
|
||||||
|
rowVPad = 4.dp,
|
||||||
|
dayHeaderTopPad = 10.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val REGULAR_METRICS = AgendaMetrics(
|
||||||
|
title = 18.sp, // †
|
||||||
|
dayHeader = 14.sp, // M3 Title Small
|
||||||
|
eventTitle = 16.sp, // M3 Body Large
|
||||||
|
eventTime = 14.sp, // M3 Body Medium
|
||||||
|
placeholder = 16.sp, // M3 Body Large
|
||||||
|
message = 16.sp, // M3 Body Large
|
||||||
|
stripeH = 40.dp,
|
||||||
|
iconImage = 24.dp,
|
||||||
|
iconBox = 44.dp,
|
||||||
|
rowVPad = 5.dp,
|
||||||
|
dayHeaderTopPad = 11.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LARGE_METRICS = AgendaMetrics(
|
||||||
|
title = 20.sp, // †
|
||||||
|
dayHeader = 16.sp, // M3 Title Medium
|
||||||
|
eventTitle = 18.sp, // †
|
||||||
|
eventTime = 14.sp, // M3 Body Medium — the secondary line steps more slowly
|
||||||
|
placeholder = 18.sp, // † on purpose, so the title keeps its
|
||||||
|
message = 18.sp, // † lead and the hierarchy survives.
|
||||||
|
stripeH = 46.dp,
|
||||||
|
iconImage = 26.dp,
|
||||||
|
iconBox = 48.dp,
|
||||||
|
rowVPad = 6.dp,
|
||||||
|
dayHeaderTopPad = 12.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val XLARGE_METRICS = AgendaMetrics(
|
||||||
|
title = 22.sp, // M3 Title Large
|
||||||
|
dayHeader = 18.sp, // †
|
||||||
|
eventTitle = 20.sp, // †
|
||||||
|
eventTime = 16.sp, // M3 Body Large
|
||||||
|
placeholder = 20.sp, // †
|
||||||
|
message = 20.sp, // †
|
||||||
|
stripeH = 52.dp,
|
||||||
|
iconImage = 28.dp,
|
||||||
|
iconBox = 52.dp,
|
||||||
|
rowVPad = 8.dp,
|
||||||
|
dayHeaderTopPad = 14.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Indexed by [WidgetScale.ordinal] so lookup allocates nothing per recomposition. */
|
||||||
|
private val AGENDA_METRICS = listOf(
|
||||||
|
COMPACT_METRICS,
|
||||||
|
REGULAR_METRICS,
|
||||||
|
LARGE_METRICS,
|
||||||
|
XLARGE_METRICS,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun metricsFor(scale: WidgetScale): AgendaMetrics = AGENDA_METRICS[scale.ordinal]
|
||||||
@@ -5,7 +5,7 @@ import android.content.res.Configuration
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
@@ -13,9 +13,11 @@ import androidx.glance.GlanceModifier
|
|||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
import androidx.glance.Image
|
import androidx.glance.Image
|
||||||
import androidx.glance.ImageProvider
|
import androidx.glance.ImageProvider
|
||||||
|
import androidx.glance.LocalSize
|
||||||
import androidx.glance.action.ActionParameters
|
import androidx.glance.action.ActionParameters
|
||||||
import androidx.glance.action.clickable
|
import androidx.glance.action.clickable
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
|
import androidx.glance.appwidget.SizeMode
|
||||||
import androidx.glance.appwidget.action.ActionCallback
|
import androidx.glance.appwidget.action.ActionCallback
|
||||||
import androidx.glance.appwidget.action.actionRunCallback
|
import androidx.glance.appwidget.action.actionRunCallback
|
||||||
import androidx.glance.appwidget.action.actionStartActivity
|
import androidx.glance.appwidget.action.actionStartActivity
|
||||||
@@ -48,14 +50,19 @@ import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.anchorTodayIfMissing
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
@@ -65,7 +72,6 @@ import kotlinx.datetime.TimeZone
|
|||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,10 +97,31 @@ internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
|||||||
*/
|
*/
|
||||||
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-instance Glance state key holding the "always show today" toggle (#35).
|
||||||
|
* Read reactively in the composition for the same reason as [AGENDA_RANGE_KEY] —
|
||||||
|
* so toggling the setting reflects on the live widget without depending on the
|
||||||
|
* `provideGlance` preamble re-running.
|
||||||
|
*/
|
||||||
|
internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_today")
|
||||||
|
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
// Exact so the composition sees the widget's live size and can scale type/rows
|
||||||
|
// from it ([scaleFor]/[metricsFor]); at the default size that resolves to
|
||||||
|
// COMPACT, i.e. the layout is unchanged (#51). MonthWidget already does the
|
||||||
|
// same.
|
||||||
|
//
|
||||||
|
// Note Exact still asks Glance for one RemoteViews per host size (typically
|
||||||
|
// portrait + landscape) where the old SizeMode.Single produced exactly one —
|
||||||
|
// so the serialized payload roughly doubles. That is why the row list below is
|
||||||
|
// capped: an uncapped agenda (the range goes up to AgendaRange.MAX_CUSTOM_DAYS
|
||||||
|
// = 365) could otherwise push the RemoteViews past the binder transaction
|
||||||
|
// limit and the host would just show "Problem loading widget".
|
||||||
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val data = context.loadAgendaWidgetData()
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
@@ -114,24 +141,41 @@ class RefreshAgendaAction : ActionCallback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||||
|
* stays well inside the binder transaction limit regardless of range and calendar
|
||||||
|
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
|
||||||
|
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||||
|
* risking a failed update for.
|
||||||
|
*/
|
||||||
|
private const val MAX_AGENDA_ROWS = 100
|
||||||
|
|
||||||
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
/** Flat row model so the [LazyColumn] can mix day headers and events. */
|
||||||
private sealed interface AgendaRow {
|
private sealed interface AgendaRow {
|
||||||
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
data class Header(val date: LocalDate, val today: LocalDate) : AgendaRow
|
||||||
data class Event(val event: EventInstance) : AgendaRow
|
data class Event(val date: LocalDate, val event: EventInstance) : AgendaRow
|
||||||
|
/** "Nothing left today" line under an anchored, event-less today (#35). */
|
||||||
|
data class Placeholder(val date: LocalDate) : AgendaRow
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||||
|
// Type and row metrics scale with the widget's live size (SizeMode.Exact); a
|
||||||
|
// short/compact widget resolves to COMPACT, leaving the layout unchanged (#51).
|
||||||
|
// The stripe is then re-resolved against the system font scale so it tracks the
|
||||||
|
// sp-sized text beside it instead of drifting at large accessibility settings.
|
||||||
|
val fontScale = androidx.glance.LocalContext.current.resources.configuration.fontScale
|
||||||
|
val metrics = metricsFor(scaleFor(LocalSize.current)).scaledForFont(fontScale)
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(GlanceTheme.colors.surface)
|
.background(GlanceTheme.colors.surface)
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
) {
|
) {
|
||||||
AgendaHeader()
|
AgendaHeader(metrics)
|
||||||
Spacer(GlanceModifier.height(4.dp))
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
when (data) {
|
when (data) {
|
||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission, metrics)
|
||||||
is AgendaWidgetData.Ready -> {
|
is AgendaWidgetData.Ready -> {
|
||||||
// Range read reactively from per-instance Glance state (falls back
|
// Range read reactively from per-instance Glance state (falls back
|
||||||
// to the saved pref for a freshly placed widget), then the wide
|
// to the saved pref for a freshly placed widget), then the wide
|
||||||
@@ -139,13 +183,17 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||||
val pastDisplay =
|
val pastDisplay =
|
||||||
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||||
|
val showToday = currentState(AGENDA_SHOW_TODAY_STATE_KEY) ?: data.savedShowToday
|
||||||
val rangeEnd = data.today.plus(
|
val rangeEnd = data.today.plus(
|
||||||
range.dayCount(data.today, data.weekStart) - 1,
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
// Slice to the chosen range, then drop finished events (and any day
|
// Slice to the chosen range, then drop finished events (and any day
|
||||||
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||||
val visibleDays = data.days
|
// Finally re-anchor today (as an empty placeholder day) when the
|
||||||
|
// "always show today" pref is on and today has no events left (#35).
|
||||||
|
val visibleDays = anchorTodayIfMissing(
|
||||||
|
days = data.days
|
||||||
.filter { it.date <= rangeEnd }
|
.filter { it.date <= rangeEnd }
|
||||||
.let { days ->
|
.let { days ->
|
||||||
if (pastDisplay == PastEventDisplay.HIDE) {
|
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
@@ -156,26 +204,41 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
} else {
|
} else {
|
||||||
days
|
days
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
today = data.today,
|
||||||
|
enabled = showToday,
|
||||||
|
)
|
||||||
if (visibleDays.isEmpty()) {
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title, metrics)
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
visibleDays.forEach { day ->
|
visibleDays.forEach { day ->
|
||||||
add(AgendaRow.Header(day.date, data.today))
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
if (day.events.isEmpty()) {
|
||||||
|
add(AgendaRow.Placeholder(day.date))
|
||||||
|
} else {
|
||||||
|
day.events.forEach { add(AgendaRow.Event(day.date, it)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Bound the payload, then drop a day header the cut left
|
||||||
|
// stranded with nothing under it.
|
||||||
|
.take(MAX_AGENDA_ROWS)
|
||||||
|
.dropLastWhile { it is AgendaRow.Header }
|
||||||
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
LazyColumn(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today, metrics)
|
||||||
|
is AgendaRow.Placeholder -> PlaceholderRow(row.date, metrics)
|
||||||
is AgendaRow.Event -> EventRow(
|
is AgendaRow.Event -> EventRow(
|
||||||
event = row.event,
|
event = row.event,
|
||||||
|
day = row.date,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
soften = data.soften,
|
||||||
is24Hour = data.is24Hour,
|
is24Hour = data.is24Hour,
|
||||||
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||||
row.event.hasEnded(data.now),
|
row.event.hasEnded(data.now),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +250,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaHeader() {
|
private fun AgendaHeader(metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
@@ -199,7 +262,7 @@ private fun AgendaHeader() {
|
|||||||
text = context.getString(R.string.widget_agenda_title),
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = GlanceTheme.colors.primary,
|
color = GlanceTheme.colors.primary,
|
||||||
fontSize = 16.sp,
|
fontSize = metrics.title,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
@@ -212,6 +275,7 @@ private fun AgendaHeader() {
|
|||||||
resId = R.drawable.ic_widget_refresh,
|
resId = R.drawable.ic_widget_refresh,
|
||||||
contentDescription = context.getString(R.string.widget_refresh),
|
contentDescription = context.getString(R.string.widget_refresh),
|
||||||
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
onClick = GlanceModifier.clickable(actionRunCallback<RefreshAgendaAction>()),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
resId = R.drawable.ic_widget_add,
|
resId = R.drawable.ic_widget_add,
|
||||||
@@ -221,39 +285,77 @@ private fun AgendaHeader() {
|
|||||||
MainActivity.openCreateIntent(context, today(systemZone())),
|
MainActivity.openCreateIntent(context, today(systemZone())),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
metrics = metrics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun IconButton(resId: Int, contentDescription: String, onClick: GlanceModifier) {
|
private fun IconButton(
|
||||||
|
resId: Int,
|
||||||
|
contentDescription: String,
|
||||||
|
onClick: GlanceModifier,
|
||||||
|
metrics: AgendaMetrics,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.size(40.dp).then(onClick),
|
modifier = GlanceModifier.size(metrics.iconBox).then(onClick),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
provider = ImageProvider(resId),
|
provider = ImageProvider(resId),
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurfaceVariant),
|
||||||
modifier = GlanceModifier.size(22.dp),
|
modifier = GlanceModifier.size(metrics.iconImage),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
private fun DayHeaderRow(date: LocalDate, today: LocalDate, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Text(
|
Text(
|
||||||
text = agendaDayLabel(context, date, today),
|
text = agendaDayLabel(context, date, today),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
color = if (date == today) GlanceTheme.colors.primary
|
color = if (date == today) GlanceTheme.colors.primary
|
||||||
else GlanceTheme.colors.onSurfaceVariant,
|
else GlanceTheme.colors.onSurfaceVariant,
|
||||||
fontSize = 13.sp,
|
fontSize = metrics.dayHeader,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 8.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)
|
.padding(
|
||||||
|
start = 8.dp,
|
||||||
|
end = 8.dp,
|
||||||
|
top = metrics.dayHeaderTopPad,
|
||||||
|
bottom = metrics.rowVPad,
|
||||||
|
)
|
||||||
|
.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "No more events today" under an anchored, event-less today (#35). Kept plain —
|
||||||
|
* a muted line indented to the event titles — since the widget's event rows are
|
||||||
|
* plain too (a stripe + text, not cards), so a card here would look out of place.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PlaceholderRow(date: LocalDate, metrics: AgendaMetrics) {
|
||||||
|
val context = androidx.glance.LocalContext.current
|
||||||
|
Text(
|
||||||
|
text = context.getString(R.string.agenda_no_more_today),
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.placeholder),
|
||||||
|
modifier = GlanceModifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
start = TEXT_INDENT,
|
||||||
|
end = 8.dp,
|
||||||
|
top = 2.dp,
|
||||||
|
bottom = metrics.rowVPad + 2.dp,
|
||||||
|
)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
MainActivity.openDateIntent(context, date, CalendarView.Agenda),
|
||||||
@@ -263,19 +365,27 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
private fun EventRow(
|
||||||
|
event: EventInstance,
|
||||||
|
day: LocalDate,
|
||||||
|
dark: Boolean,
|
||||||
|
soften: Boolean,
|
||||||
|
is24Hour: Boolean,
|
||||||
|
dimmed: Boolean,
|
||||||
|
metrics: AgendaMetrics,
|
||||||
|
) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||||
val stripeColor = pastelize(event.color, dark).let {
|
val stripeColor = eventFill(event.color, dark, soften).let {
|
||||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||||
}
|
}
|
||||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 4.dp, vertical = 4.dp)
|
.padding(horizontal = ROW_H_PAD, vertical = metrics.rowVPad)
|
||||||
.clickable(
|
.clickable(
|
||||||
actionStartActivity(
|
actionStartActivity(
|
||||||
MainActivity.openEventIntent(
|
MainActivity.openEventIntent(
|
||||||
@@ -291,29 +401,29 @@ private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dim
|
|||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(5.dp)
|
.width(STRIPE_WIDTH)
|
||||||
.height(36.dp)
|
.height(metrics.stripeH)
|
||||||
.cornerRadius(3.dp)
|
.cornerRadius(3.dp)
|
||||||
.background(stripeColor),
|
.background(stripeColor),
|
||||||
) {}
|
) {}
|
||||||
Spacer(GlanceModifier.width(10.dp))
|
Spacer(GlanceModifier.width(STRIPE_GAP))
|
||||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, is24Hour),
|
text = eventTimeSummary(context, event, day, is24Hour),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.eventTime),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WidgetMessage(resId: Int) {
|
private fun WidgetMessage(resId: Int, metrics: AgendaMetrics) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
modifier = GlanceModifier.fillMaxSize().padding(16.dp),
|
||||||
@@ -321,7 +431,7 @@ private fun WidgetMessage(resId: Int) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(resId),
|
text = context.getString(resId),
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 14.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = metrics.message),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,17 +447,26 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
|||||||
}
|
}
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day)
|
||||||
val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale)
|
// Weekday + date in the locale's own field order (compact: no year), rather
|
||||||
val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale)
|
// than a hardcoded day-month layout.
|
||||||
val formatted = "$weekday, ${date.day} $monthName"
|
val formatted = localizedDateFormatter(locale, "EEEdMMM").format(java)
|
||||||
return if (relative != null) "$relative · $formatted" else formatted
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
|
private fun eventTimeSummary(
|
||||||
val time = if (event.isAllDay) {
|
context: Context,
|
||||||
context.getString(R.string.event_detail_all_day)
|
event: EventInstance,
|
||||||
} else {
|
day: LocalDate,
|
||||||
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
is24Hour: Boolean,
|
||||||
|
): String {
|
||||||
|
val time = when (val label = agendaTimeLabel(event, day, zone())) {
|
||||||
|
AgendaTimeLabel.AllDay -> context.getString(R.string.event_detail_all_day)
|
||||||
|
is AgendaTimeLabel.Starts ->
|
||||||
|
context.getString(R.string.agenda_span_starts, formatTime(label.start, is24Hour))
|
||||||
|
is AgendaTimeLabel.Ends ->
|
||||||
|
context.getString(R.string.agenda_span_ends, formatTime(label.end, is24Hour))
|
||||||
|
is AgendaTimeLabel.Range ->
|
||||||
|
"${formatTime(label.start, is24Hour)} – ${formatTime(label.end, is24Hour)}"
|
||||||
}
|
}
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
return if (location != null) "$time · $location" else time
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.widget.month
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
@@ -50,14 +49,18 @@ import de.jeanlucmakiola.calendula.MainActivity
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.MonthWidgetSource
|
||||||
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
import de.jeanlucmakiola.calendula.widget.loadMonthWidgetSource
|
||||||
import de.jeanlucmakiola.calendula.widget.systemZone
|
import de.jeanlucmakiola.calendula.widget.systemZone
|
||||||
import de.jeanlucmakiola.calendula.widget.today
|
import de.jeanlucmakiola.calendula.widget.today
|
||||||
|
import de.jeanlucmakiola.calendula.widget.widgetEntryPoint
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -76,9 +79,6 @@ private val LANE_HEIGHT = 14.dp
|
|||||||
private val DAY_NUMBER_HEIGHT = 18.dp
|
private val DAY_NUMBER_HEIGHT = 18.dp
|
||||||
private val GRID_HPADDING = 8.dp
|
private val GRID_HPADDING = 8.dp
|
||||||
|
|
||||||
/** Dark ink that reads on the pastelized event fills, like the in-app MonthBar. */
|
|
||||||
private val EventInk = ColorProvider(Color(0xDE000000))
|
|
||||||
|
|
||||||
private fun currentMonthIndex(zone: TimeZone): Int {
|
private fun currentMonthIndex(zone: TimeZone): Int {
|
||||||
val t = today(zone)
|
val t = today(zone)
|
||||||
return t.year * 12 + t.month.ordinal
|
return t.year * 12 + t.month.ordinal
|
||||||
@@ -107,9 +107,12 @@ class MonthWidget : GlanceAppWidget() {
|
|||||||
val source = context.loadMonthWidgetSource()
|
val source = context.loadMonthWidgetSource()
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
// Read fresh (not through the cached source) so toggling the softener
|
||||||
|
// redraws with the new choice; it's one cheap DataStore read.
|
||||||
|
val soften = context.widgetEntryPoint().settingsPrefs().softenCalendarColors.first()
|
||||||
provideContent {
|
provideContent {
|
||||||
CalendulaGlanceTheme {
|
CalendulaGlanceTheme {
|
||||||
MonthWidgetBody(source = source, dark = dark)
|
MonthWidgetBody(source = source, dark = dark, soften = soften)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +143,7 @@ class ResetMonthAction : ActionCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean, soften: Boolean) {
|
||||||
Column(
|
Column(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -160,7 +163,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
|||||||
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
val colW = (LocalSize.current.width - GRID_HPADDING * 2) / 7
|
||||||
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
val weeks = layoutMonthWeeks(ym, source.weekStart, source.instances, zone)
|
||||||
|
|
||||||
MonthHeader(label = monthLabel(ym))
|
MonthHeader(label = monthLabel(ym, source.today.year))
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
WeekdayHeader(weekStart = source.weekStart, colW = colW)
|
||||||
weeks.forEach { week ->
|
weeks.forEach { week ->
|
||||||
@@ -169,6 +172,7 @@ private fun MonthWidgetBody(source: MonthWidgetSource, dark: Boolean) {
|
|||||||
currentMonth = ym.month,
|
currentMonth = ym.month,
|
||||||
today = source.today,
|
today = source.today,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
soften = soften,
|
||||||
colW = colW,
|
colW = colW,
|
||||||
modifier = GlanceModifier.defaultWeight(),
|
modifier = GlanceModifier.defaultWeight(),
|
||||||
)
|
)
|
||||||
@@ -278,6 +282,7 @@ private fun WeekRow(
|
|||||||
currentMonth: Month,
|
currentMonth: Month,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
soften: Boolean,
|
||||||
colW: Dp,
|
colW: Dp,
|
||||||
modifier: GlanceModifier,
|
modifier: GlanceModifier,
|
||||||
) {
|
) {
|
||||||
@@ -297,7 +302,7 @@ private fun WeekRow(
|
|||||||
// One lane row per event row. A multi-day span is a single Box spanning
|
// One lane row per event row. A multi-day span is a single Box spanning
|
||||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||||
repeat(MAX_LANES) { lane ->
|
repeat(MAX_LANES) { lane ->
|
||||||
LaneRow(week = week, lane = lane, dark = dark, colW = colW)
|
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||||
Spacer(GlanceModifier.height(1.dp))
|
Spacer(GlanceModifier.height(1.dp))
|
||||||
}
|
}
|
||||||
OverflowRow(week = week, colW = colW)
|
OverflowRow(week = week, colW = colW)
|
||||||
@@ -346,7 +351,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
var col = 0
|
var col = 0
|
||||||
@@ -354,12 +359,12 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||||
if (span != null) {
|
if (span != null) {
|
||||||
val cols = span.endCol - col + 1
|
val cols = span.endCol - col + 1
|
||||||
SpanBar(event = span.event, dark = dark, width = colW * cols)
|
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
|
||||||
col = span.endCol + 1
|
col = span.endCol + 1
|
||||||
} else {
|
} else {
|
||||||
val timed = timedEventAt(week, lane, col, week.days[col])
|
val timed = timedEventAt(week, lane, col, week.days[col])
|
||||||
if (timed != null) {
|
if (timed != null) {
|
||||||
SpanBar(event = timed, dark = dark, width = colW)
|
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
|
||||||
} else {
|
} else {
|
||||||
// Empty lane cell: a tap opens that day, so blank space in a
|
// Empty lane cell: a tap opens that day, so blank space in a
|
||||||
// day column is a day-open target just like the number is.
|
// day column is a day-open target just like the number is.
|
||||||
@@ -378,8 +383,9 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
|
|
||||||
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
/** A single connected, rounded event bar [width] wide with its clipped title. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width: Dp) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val fill = eventFill(event.color, dark, soften)
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(width)
|
.width(width)
|
||||||
@@ -402,13 +408,13 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
|||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.cornerRadius(4.dp)
|
.cornerRadius(4.dp)
|
||||||
.background(pastelize(event.color, dark)),
|
.background(fill),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = EventInk, fontSize = 9.sp),
|
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
|
||||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -475,9 +481,13 @@ private fun PermissionMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun monthLabel(month: YearMonth): String {
|
// Locale.getDefault() rather than currentLocale(): Glance has no
|
||||||
val locale = Locale.getDefault()
|
// LocalConfiguration, and the widget is re-rendered on a configuration change
|
||||||
val name = java.time.Month.of(month.month.ordinal + 1)
|
// anyway (same convention as AgendaWidget).
|
||||||
.getDisplayName(JavaTextStyle.FULL, locale)
|
private fun monthLabel(month: YearMonth, currentYear: Int): String =
|
||||||
return "$name ${month.year}"
|
formatCalendarTitle(
|
||||||
}
|
date = java.time.LocalDate.of(month.year, month.month.ordinal + 1, 1),
|
||||||
|
locale = Locale.getDefault(),
|
||||||
|
currentYear = currentYear,
|
||||||
|
skeleton = "LLLL",
|
||||||
|
)
|
||||||
|
|||||||
@@ -310,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>
|
||||||
@@ -474,4 +474,20 @@
|
|||||||
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</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_anniversary">Jahrestag von {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_custom">{name}</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>
|
||||||
@@ -463,4 +463,7 @@
|
|||||||
<string name="special_dates_calendar_custom">Date speciali</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_birthday">Compleanno di {name} ({year})</string>
|
||||||
<string name="special_dates_default_title_anniversary">Anniversario 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>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Calendula</string>
|
<string name="app_name">Calendula</string>
|
||||||
<string name="app_tagline">A modern calendar.</string>
|
<string name="app_tagline">A modern calendar.</string>
|
||||||
|
<!-- Alternative launcher label offered by the "App name" setting (issue #44).
|
||||||
|
translatable="false": this string becomes a launcher component's label, so
|
||||||
|
it must never be changed by an unreviewed community translation. -->
|
||||||
|
<string name="app_name_calendar_alias" translatable="false">Calendar</string>
|
||||||
|
|
||||||
<!-- Loading / Failure / Success generic strings (used across screens) -->
|
<!-- Loading / Failure / Success generic strings (used across screens) -->
|
||||||
<string name="state_loading">Loading…</string>
|
<string name="state_loading">Loading…</string>
|
||||||
@@ -49,6 +53,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>
|
||||||
@@ -98,6 +103,18 @@
|
|||||||
<string name="event_edit_availability">Availability</string>
|
<string name="event_edit_availability">Availability</string>
|
||||||
<string name="event_edit_visibility">Visibility</string>
|
<string name="event_edit_visibility">Visibility</string>
|
||||||
|
|
||||||
|
<!-- Event form — time zone -->
|
||||||
|
<string name="event_edit_timezone_device">Device time zone</string>
|
||||||
|
<string name="event_edit_timezone_device_summary">Follows wherever you are</string>
|
||||||
|
<string name="event_edit_timezone_search">Search time zones</string>
|
||||||
|
<string name="event_edit_timezone_recent">Recent</string>
|
||||||
|
<string name="event_edit_timezone_all">All time zones</string>
|
||||||
|
<string name="event_edit_timezone_none">No time zone matches “%1$s”</string>
|
||||||
|
<!-- Shown under the time fields when the event is pinned to another zone:
|
||||||
|
the same event expressed where the user actually is. %1$s is a time
|
||||||
|
range, e.g. "2:00 PM – 3:00 PM". -->
|
||||||
|
<string name="event_edit_timezone_local_time">%1$s your time</string>
|
||||||
|
|
||||||
<!-- Event form — per-event color -->
|
<!-- Event form — per-event color -->
|
||||||
<string name="event_edit_color">Color</string>
|
<string name="event_edit_color">Color</string>
|
||||||
<string name="event_edit_color_default">Calendar color</string>
|
<string name="event_edit_color_default">Calendar color</string>
|
||||||
@@ -117,6 +134,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>
|
||||||
@@ -125,6 +152,9 @@
|
|||||||
<string name="recurrence_unit_weeks">weeks</string>
|
<string name="recurrence_unit_weeks">weeks</string>
|
||||||
<string name="recurrence_unit_months">months</string>
|
<string name="recurrence_unit_months">months</string>
|
||||||
<string name="recurrence_unit_years">years</string>
|
<string name="recurrence_unit_years">years</string>
|
||||||
|
<!-- Shown in place of the live rule read-out when an amount field holds a
|
||||||
|
value outside 1–999 (i.e. 0), so the greyed-out OK button has a reason. -->
|
||||||
|
<string name="event_edit_recurrence_incomplete">Enter a number from 1 to 999</string>
|
||||||
<string name="event_edit_recurrence_ends">Ends</string>
|
<string name="event_edit_recurrence_ends">Ends</string>
|
||||||
<string name="event_edit_recurrence_end_never">Never</string>
|
<string name="event_edit_recurrence_end_never">Never</string>
|
||||||
<string name="event_edit_recurrence_end_until">On a date</string>
|
<string name="event_edit_recurrence_end_until">On a date</string>
|
||||||
@@ -226,6 +256,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 +275,13 @@
|
|||||||
<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>
|
||||||
|
<!-- Time line for a multi-day event, on its first / last day (%1$s is a time, e.g. "14:00"). -->
|
||||||
|
<string name="agenda_span_starts">Starts %1$s</string>
|
||||||
|
<string name="agenda_span_ends">Ends %1$s</string>
|
||||||
|
|
||||||
|
<!-- Jump to today (toolbar icon button; see settings_today_toolbar) -->
|
||||||
|
<string name="today_jump_action">Go to today</string>
|
||||||
|
|
||||||
<!-- Event search -->
|
<!-- Event search -->
|
||||||
<string name="search_action">Search</string>
|
<string name="search_action">Search</string>
|
||||||
@@ -278,6 +318,8 @@
|
|||||||
<string name="settings_default_view">Default view</string>
|
<string name="settings_default_view">Default view</string>
|
||||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||||
|
<string name="settings_soften_colors">Soften calendar colours</string>
|
||||||
|
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string>
|
||||||
<string name="settings_font_headings">Headings font</string>
|
<string name="settings_font_headings">Headings font</string>
|
||||||
<string name="settings_font_body">Body font</string>
|
<string name="settings_font_body">Body font</string>
|
||||||
<string name="settings_font_system">System default</string>
|
<string name="settings_font_system">System default</string>
|
||||||
@@ -291,6 +333,10 @@
|
|||||||
<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">Week numbers</string>
|
||||||
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
||||||
|
<string name="settings_today_toolbar">Today button in toolbar</string>
|
||||||
|
<string name="settings_today_toolbar_summary">Show a jump-to-today button in the toolbar instead of a floating button</string>
|
||||||
|
<string name="settings_app_name">App name</string>
|
||||||
|
<string name="settings_app_name_summary">Show Calendula as “Calendar” in your launcher. Only the launcher name changes; the icon may move to a new spot after switching.</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>
|
||||||
@@ -308,6 +354,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>
|
||||||
@@ -324,6 +372,20 @@
|
|||||||
<item quantity="other">%d days</item>
|
<item quantity="other">%d days</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_section_views">Views</string>
|
<string name="settings_section_views">Views</string>
|
||||||
|
<!-- Month view style (#38, #53) -->
|
||||||
|
<string name="settings_month_header">Month view</string>
|
||||||
|
<string name="settings_month_view_style">Month view style</string>
|
||||||
|
<string name="month_style_paged">Pages</string>
|
||||||
|
<string name="month_style_paged_summary">One month fills the screen. Swipe left or right to change month.</string>
|
||||||
|
<string name="month_style_continuous">Scrolling months</string>
|
||||||
|
<string name="month_style_continuous_summary">Each month sits under its own heading, with a little space setting it apart from the next.</string>
|
||||||
|
<string name="month_style_dense">Seamless weeks</string>
|
||||||
|
<string name="month_style_dense_summary">The weeks run on without a break, each month flowing straight into the next with no gap between them.</string>
|
||||||
|
<string name="month_style_split">Split</string>
|
||||||
|
<string name="month_style_split_summary">A compact grid marks the days that have events, and the day you tap is listed underneath.</string>
|
||||||
|
<string name="month_split_no_events">Nothing scheduled</string>
|
||||||
|
<string name="month_split_expand">Show the whole month</string>
|
||||||
|
<string name="month_split_collapse">Show the day\'s events</string>
|
||||||
<string name="settings_quick_switch_header">Quick-switch button</string>
|
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||||
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
||||||
<string name="settings_drawer_order_header">Navigation menu</string>
|
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||||
@@ -363,7 +425,7 @@
|
|||||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||||
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
|
<string name="settings_views_subtitle">Month layout, quick-switch button, menu order</string>
|
||||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||||
|
|||||||
@@ -11,6 +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 android:name="zh-CN" />
|
||||||
</locale-config>
|
</locale-config>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.appname
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision logic behind the app-name toggle (issue #44). The framework seam
|
||||||
|
* ([LauncherNameManager.set]/[current], which call `PackageManager`) is covered
|
||||||
|
* on-device; these tests pin the read interpretation and the write ordering.
|
||||||
|
*/
|
||||||
|
class LauncherNameManagerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `enabled calendar alias reads as CALENDAR`() {
|
||||||
|
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED))
|
||||||
|
.isEqualTo(LauncherName.CALENDAR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `default and disabled calendar alias read as CALENDULA`() {
|
||||||
|
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT))
|
||||||
|
.isEqualTo(LauncherName.CALENDULA)
|
||||||
|
assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED))
|
||||||
|
.isEqualTo(LauncherName.CALENDULA)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `write plan enables the target alias before disabling the other`() {
|
||||||
|
val toCalendar = aliasWritePlan(LauncherName.CALENDAR)
|
||||||
|
assertThat(toCalendar).containsExactly(
|
||||||
|
AliasStateChange(LauncherAlias.CALENDAR, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = false),
|
||||||
|
).inOrder()
|
||||||
|
|
||||||
|
val toCalendula = aliasWritePlan(LauncherName.CALENDULA)
|
||||||
|
assertThat(toCalendula).containsExactly(
|
||||||
|
AliasStateChange(LauncherAlias.DEFAULT, enabled = true),
|
||||||
|
AliasStateChange(LauncherAlias.CALENDAR, enabled = false),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `write plan never disables both aliases in the same step`() {
|
||||||
|
// The first step is always an enable, so the launcher can never observe a
|
||||||
|
// zero-entry transient regardless of switch direction.
|
||||||
|
for (target in LauncherName.entries) {
|
||||||
|
assertThat(aliasWritePlan(target).first().enabled).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ class EventWriteMapperTest {
|
|||||||
isAllDay: Boolean = false,
|
isAllDay: Boolean = false,
|
||||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
|
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)),
|
||||||
): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end)
|
timezone: String? = null,
|
||||||
|
): EventForm = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
timezone = timezone,
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `timed event resolves wall clock in the given zone`() {
|
fun `timed event resolves wall clock in the given zone`() {
|
||||||
@@ -30,6 +37,26 @@ class EventWriteMapperTest {
|
|||||||
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pinned zone wins over the device zone`() {
|
||||||
|
val times = form(timezone = "America/New_York").toWriteTimes(berlin)
|
||||||
|
// 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give.
|
||||||
|
assertThat(times.timezone).isEqualTo("America/New_York")
|
||||||
|
assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unparseable pinned zone falls back to the device zone`() {
|
||||||
|
val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin)
|
||||||
|
assertThat(times.timezone).isEqualTo("Europe/Berlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all-day ignores a pinned zone and stays UTC`() {
|
||||||
|
val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin)
|
||||||
|
assertThat(times.timezone).isEqualTo("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all-day event lives at UTC midnights with exclusive end`() {
|
fun `all-day event lives at UTC midnights with exclusive end`() {
|
||||||
val times = form(isAllDay = true).toWriteTimes(berlin)
|
val times = form(isAllDay = true).toWriteTimes(berlin)
|
||||||
@@ -96,8 +123,18 @@ class EventWriteMapperTest {
|
|||||||
|
|
||||||
private val seriesStart = 1_700_000_000_000L
|
private val seriesStart = 1_700_000_000_000L
|
||||||
|
|
||||||
private fun update(original: EventForm, updated: EventForm): Map<String, Any?> =
|
private fun update(
|
||||||
buildEventUpdateValues(original, updated, seriesStart, berlin)
|
original: EventForm,
|
||||||
|
updated: EventForm,
|
||||||
|
series: Long = seriesStart,
|
||||||
|
): Map<String, Any?> = buildEventUpdateValues(original, updated, series, berlin)
|
||||||
|
|
||||||
|
/** The instant [local] names in [zoneId], as the provider would store it. */
|
||||||
|
private fun instantAt(local: String, zoneId: String): Long =
|
||||||
|
java.time.LocalDateTime.parse(local)
|
||||||
|
.atZone(java.time.ZoneId.of(zoneId))
|
||||||
|
.toInstant()
|
||||||
|
.toEpochMilli()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `pristine form produces no values`() {
|
fun `pristine form produces no values`() {
|
||||||
@@ -105,6 +142,42 @@ class EventWriteMapperTest {
|
|||||||
assertThat(update(original, original.copy())).isEmpty()
|
assertThat(update(original, original.copy())).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `editing the time of a pinned event keeps its zone`() {
|
||||||
|
// The regression this guards: the update used to stamp the device zone
|
||||||
|
// over the event's own, silently un-anchoring a foreign-zone event so it
|
||||||
|
// stopped tracking that zone across DST.
|
||||||
|
val original = form(timezone = "America/New_York")
|
||||||
|
val values = update(original, original.copy(title = "Standup", start = original.start))
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE)
|
||||||
|
|
||||||
|
val moved = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)),
|
||||||
|
)
|
||||||
|
assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE])
|
||||||
|
.isEqualTo("America/New_York")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `changing only the zone still moves the event`() {
|
||||||
|
// Same wall-clock, different zone: the instant moves, so DTSTART must be
|
||||||
|
// rewritten even though start/end compare equal.
|
||||||
|
val original = form()
|
||||||
|
val values = update(original, original.copy(timezone = "America/New_York"))
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
|
||||||
|
// 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later.
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unpinning back to the device zone rewrites the times`() {
|
||||||
|
val original = form(timezone = "America/New_York")
|
||||||
|
val values = update(original, original.copy(timezone = null))
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `text-only edit writes just the changed columns`() {
|
fun `text-only edit writes just the changed columns`() {
|
||||||
val original = form()
|
val original = form()
|
||||||
@@ -156,6 +229,77 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pinning a recurring event to another zone keeps the series wall clock`() {
|
||||||
|
// The regression this guards: the series DTSTART used to move by a
|
||||||
|
// millisecond delta measured at the *edited occurrence*. Here the series
|
||||||
|
// anchor sits in January (Berlin CET, +1) and the edited occurrence in
|
||||||
|
// July (Berlin CEST, +2), so the July delta is an hour off for January —
|
||||||
|
// the whole series would have drifted to 10:00 Tokyo.
|
||||||
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
|
||||||
|
val values = update(original, original.copy(timezone = "Asia/Tokyo"), series)
|
||||||
|
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Asia/Tokyo")
|
||||||
|
// The anchor still reads 09:00 — now 09:00 in Tokyo, not 10:00.
|
||||||
|
assertThat(values[CalendarContract.Events.DTSTART])
|
||||||
|
.isEqualTo(instantAt("2026-01-07T09:00", "Asia/Tokyo"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a time edit moves the series anchor in wall clock across a DST boundary`() {
|
||||||
|
// Anchor in winter, edited occurrence in summer: pushing the occurrence
|
||||||
|
// one hour later must leave the anchor at 10:00 winter time, not at an
|
||||||
|
// instant that re-reads as 11:00 once the offset differs.
|
||||||
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val moved = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||||
|
.isEqualTo(instantAt("2026-01-07T10:00", "Europe/Berlin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moving a recurring occurrence to another day shifts the anchor by whole days`() {
|
||||||
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
val moved = original.copy(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART])
|
||||||
|
.isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `switching a recurring event to all-day anchors the series on a UTC midnight`() {
|
||||||
|
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||||
|
val original = form(
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||||
|
).copy(rrule = "FREQ=WEEKLY")
|
||||||
|
|
||||||
|
val values = update(original, original.copy(isAllDay = true), series)
|
||||||
|
|
||||||
|
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||||
|
val dtStart = values[CalendarContract.Events.DTSTART] as Long
|
||||||
|
assertThat(dtStart % (24L * 60 * 60 * 1000)).isEqualTo(0L)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `adding a recurrence keeps the times and writes rule plus duration`() {
|
fun `adding a recurrence keeps the times and writes rule plus duration`() {
|
||||||
val original = form()
|
val original = form()
|
||||||
@@ -260,6 +404,77 @@ class EventWriteMapperTest {
|
|||||||
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
.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
|
||||||
@@ -338,4 +553,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,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -125,6 +126,30 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
|
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `month view style defaults to paged and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged)
|
||||||
|
|
||||||
|
prefs.setMonthViewStyle(MonthViewStyle.Split)
|
||||||
|
assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Split)
|
||||||
|
|
||||||
|
prefs.setMonthViewStyle(MonthViewStyle.Continuous)
|
||||||
|
assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Continuous)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage stored month view style falls back to paged`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.MONTH_VIEW_STYLE_KEY] = "Carousel"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
||||||
val store = newDataStore(tempDir)
|
val store = newDataStore(tempDir)
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class EventFormTest {
|
|||||||
attendees: List<Attendee> = emptyList(),
|
attendees: List<Attendee> = emptyList(),
|
||||||
eventColor: Int? = null,
|
eventColor: Int? = null,
|
||||||
eventColorKey: String? = null,
|
eventColorKey: String? = null,
|
||||||
|
eventTimezone: String? = null,
|
||||||
): EventDetail = EventDetail(
|
): EventDetail = EventDetail(
|
||||||
instance = EventInstance(
|
instance = EventInstance(
|
||||||
instanceId = 1L,
|
instanceId = 1L,
|
||||||
@@ -138,6 +139,7 @@ class EventFormTest {
|
|||||||
accessLevel = accessLevel,
|
accessLevel = accessLevel,
|
||||||
eventColor = eventColor,
|
eventColor = eventColor,
|
||||||
eventColorKey = eventColorKey,
|
eventColorKey = eventColorKey,
|
||||||
|
eventTimezone = eventTimezone,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -157,6 +159,117 @@ class EventFormTest {
|
|||||||
assertThat(prefilled.description).isEqualTo("Body")
|
assertThat(prefilled.description).isEqualTo("Body")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm leaves an event in the device zone unpinned`() {
|
||||||
|
val prefilled = detail(eventTimezone = "Europe/Berlin").toEditForm(
|
||||||
|
beginMillis = 1_781_164_800_000L,
|
||||||
|
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
// Same zone as the device: pinning it would only make the picker appear
|
||||||
|
// on every ordinary event.
|
||||||
|
assertThat(prefilled.timezone).isNull()
|
||||||
|
assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm pins a foreign zone and shows the times in it`() {
|
||||||
|
val prefilled = detail(eventTimezone = "America/New_York").toEditForm(
|
||||||
|
beginMillis = 1_781_164_800_000L, // 08:00Z
|
||||||
|
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
assertThat(prefilled.timezone).isEqualTo("America/New_York")
|
||||||
|
// 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the
|
||||||
|
// event's own wall-clock, which is what a later save re-anchors to.
|
||||||
|
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0)))
|
||||||
|
assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm never pins a zone on an all-day event`() {
|
||||||
|
// All-day rows carry a nominal "UTC" that is an anchor, not a location.
|
||||||
|
val prefilled = detail(isAllDay = true, eventTimezone = "UTC").toEditForm(
|
||||||
|
beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L,
|
||||||
|
endMillis = LocalDate(2026, 6, 12).toEpochDays() * 86_400_000L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
assertThat(prefilled.timezone).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm ignores an unparseable stored zone`() {
|
||||||
|
val prefilled = detail(eventTimezone = "Mars/Olympus_Mons").toEditForm(
|
||||||
|
beginMillis = 1_781_164_800_000L,
|
||||||
|
endMillis = 1_781_164_800_000L + 3_600_000L,
|
||||||
|
zone = berlin,
|
||||||
|
)
|
||||||
|
// A malformed sync row must not be honoured, nor fail the open.
|
||||||
|
assertThat(prefilled.timezone).isNull()
|
||||||
|
assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timesIn converts a pinned event into the target zone`() {
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
||||||
|
timezone = "America/New_York",
|
||||||
|
)
|
||||||
|
val (start, end) = form.timesIn(berlin)!!
|
||||||
|
// 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2).
|
||||||
|
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0)))
|
||||||
|
assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timesIn crosses the date line when the offset pushes past midnight`() {
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)),
|
||||||
|
timezone = "America/New_York",
|
||||||
|
)
|
||||||
|
val (start, _) = form.timesIn(berlin)!!
|
||||||
|
// 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move
|
||||||
|
// with it, not just the clock.
|
||||||
|
assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timesIn tracks each zone's own DST rather than a fixed offset`() {
|
||||||
|
// In January both are on standard time: 08:00 EST is 14:00 CET — the
|
||||||
|
// same six hours as July, but only because both shifted. Late March,
|
||||||
|
// when the US has sprung forward and Europe hasn't, the gap is five.
|
||||||
|
val march = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)),
|
||||||
|
timezone = "America/New_York",
|
||||||
|
)
|
||||||
|
assertThat(march.timesIn(berlin)!!.first)
|
||||||
|
.isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timesIn returns null when there is nothing to disambiguate`() {
|
||||||
|
val base = EventForm(
|
||||||
|
calendarId = 1L,
|
||||||
|
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)),
|
||||||
|
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)),
|
||||||
|
)
|
||||||
|
// Unpinned: the form's times already are the local times.
|
||||||
|
assertThat(base.timesIn(berlin)).isNull()
|
||||||
|
// Pinned to the target itself: same thing.
|
||||||
|
assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull()
|
||||||
|
// All-day: date-anchored, so there's no zone conversion to show.
|
||||||
|
assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin))
|
||||||
|
.isNull()
|
||||||
|
// Unparseable: can't convert, mustn't throw.
|
||||||
|
assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
fun `toEditForm turns the exclusive all-day end into the last covered day`() {
|
||||||
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
// 11th..13th = UTC midnights of the 11th and the (exclusive) 14th.
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class TimeZoneCatalogTest {
|
||||||
|
|
||||||
|
// A fixed instant so DST-dependent offsets can't drift with the wall clock:
|
||||||
|
// 2026-06-11 is northern summer, i.e. Berlin on CEST and New York on EDT.
|
||||||
|
private val summer: Instant = Instant.parse("2026-06-11T12:00:00Z")
|
||||||
|
private val zones = timeZoneOptions(Locale.ENGLISH, summer)
|
||||||
|
|
||||||
|
private fun filter(query: String) = filterTimeZones(zones, query)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `catalogue holds the real zones and drops the legacy aliases`() {
|
||||||
|
assertThat(zones.map { it.id }).containsAtLeast("Europe/Berlin", "America/New_York")
|
||||||
|
// Bare aliases the tz database keeps for compatibility would double up
|
||||||
|
// the real zones in the picker.
|
||||||
|
assertThat(zones.map { it.id }).containsNoneOf("EST", "CST6CDT", "UTC")
|
||||||
|
assertThat(zones.none { it.id.startsWith("SystemV/") }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `option exposes city and region split from the id`() {
|
||||||
|
val ny = zones.first { it.id == "America/New_York" }
|
||||||
|
assertThat(ny.city).isEqualTo("New York")
|
||||||
|
assertThat(ny.region).isEqualTo("America")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `label is the id with underscores undone`() {
|
||||||
|
assertThat(zones.first { it.id == "America/New_York" }.label)
|
||||||
|
.isEqualTo("America/New York")
|
||||||
|
assertThat(zones.first { it.id == "Europe/Berlin" }.label).isEqualTo("Europe/Berlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolved abbreviation is compact, not the long name`() {
|
||||||
|
// Whatever CLDR gives ("CET"/"CEST" or a "GMT+.." fallback), it must be
|
||||||
|
// short and space-free — that's the whole point of showing it instead of
|
||||||
|
// "Central European Time".
|
||||||
|
val berlin = zones.first { it.id == "Europe/Berlin" }
|
||||||
|
assertThat(berlin.shortName).doesNotContain(" ")
|
||||||
|
assertThat(berlin.shortName.length).isLessThan(berlin.displayName.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `descriptor pairs a named abbreviation with the offset`() {
|
||||||
|
val option = TimeZoneOption(
|
||||||
|
id = "Europe/Berlin",
|
||||||
|
displayName = "Central European Time",
|
||||||
|
shortName = "CET",
|
||||||
|
offsetMinutes = 60,
|
||||||
|
)
|
||||||
|
assertThat(zoneDescriptor(option)).isEqualTo("CET · GMT+01:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `descriptor drops the offset when the abbreviation already is one`() {
|
||||||
|
// Repeating the offset after an offset-shaped abbreviation would just say
|
||||||
|
// the same thing twice.
|
||||||
|
fun descriptorFor(shortName: String, offset: Int) = zoneDescriptor(
|
||||||
|
TimeZoneOption(id = "X/Y", displayName = "", shortName = shortName, offsetMinutes = offset),
|
||||||
|
)
|
||||||
|
assertThat(descriptorFor("UTC", 0)).isEqualTo("UTC")
|
||||||
|
assertThat(descriptorFor("GMT+05:30", 330)).isEqualTo("GMT+05:30")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `offset is resolved at the given instant, not the current one`() {
|
||||||
|
val berlin = zones.first { it.id == "Europe/Berlin" }
|
||||||
|
// CEST in June, so +02:00 — a fixed +01:00 would mean we ignored DST.
|
||||||
|
assertThat(berlin.offsetMinutes).isEqualTo(120)
|
||||||
|
|
||||||
|
val winter = timeZoneOptions(Locale.ENGLISH, Instant.parse("2026-01-11T12:00:00Z"))
|
||||||
|
assertThat(winter.first { it.id == "Europe/Berlin" }.offsetMinutes).isEqualTo(60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `blank query returns everything unchanged`() {
|
||||||
|
assertThat(filter("")).isEqualTo(zones)
|
||||||
|
assertThat(filter(" ")).isEqualTo(zones)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query matches the city, ignoring case and underscores`() {
|
||||||
|
assertThat(filter("new york").map { it.id }).contains("America/New_York")
|
||||||
|
assertThat(filter("NEW YORK").map { it.id }).contains("America/New_York")
|
||||||
|
assertThat(filter("new_york").map { it.id }).contains("America/New_York")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query matches accented cities typed plainly`() {
|
||||||
|
// Sao_Paulo has no accent in the id, but its localized name does — the
|
||||||
|
// point is that a user typing plain ASCII still finds it.
|
||||||
|
assertThat(filter("sao paulo").map { it.id }).contains("America/Sao_Paulo")
|
||||||
|
assertThat(filter("zurich").map { it.id }).contains("Europe/Zurich")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query matches the full IANA id`() {
|
||||||
|
assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query matches the abbreviation, case-insensitively`() {
|
||||||
|
// "cest" isn't a city, id, or substring of "Central European Summer
|
||||||
|
// Time", so the only way these match is via the abbreviation.
|
||||||
|
val hits = filter("cest")
|
||||||
|
assertThat(hits).isNotEmpty()
|
||||||
|
assertThat(hits.map { it.id }).contains("Europe/Berlin")
|
||||||
|
// Every hit genuinely carries that abbreviation — nothing bled in.
|
||||||
|
assertThat(hits.all { it.shortName.equals("CEST", ignoreCase = true) }).isTrue()
|
||||||
|
// Case doesn't matter.
|
||||||
|
assertThat(filter("CEST").map { it.id }).isEqualTo(hits.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a city starting with the query outranks one merely containing it`() {
|
||||||
|
val ids = filter("york").map { it.id }
|
||||||
|
// "New York" contains "york"; nothing starts with it, so it should still
|
||||||
|
// surface rather than being buried.
|
||||||
|
assertThat(ids).contains("America/New_York")
|
||||||
|
|
||||||
|
// "col" starts Colombo but only appears mid-string elsewhere.
|
||||||
|
val col = filter("col").map { it.id }
|
||||||
|
assertThat(col.first()).isEqualTo("Asia/Colombo")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no match yields an empty list rather than everything`() {
|
||||||
|
assertThat(filter("zzzznotazone")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single zone resolves the same way the catalogue does`() {
|
||||||
|
val fromCatalogue = zones.first { it.id == "Europe/Berlin" }
|
||||||
|
assertThat(timeZoneOptionOf("Europe/Berlin", Locale.ENGLISH, summer))
|
||||||
|
.isEqualTo(fromCatalogue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown zone id resolves to null`() {
|
||||||
|
assertThat(timeZoneOptionOf("Mars/Olympus_Mons")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `gmt offsets format with a sign and padding`() {
|
||||||
|
assertThat(formatGmtOffset(0)).isEqualTo("GMT")
|
||||||
|
assertThat(formatGmtOffset(120)).isEqualTo("GMT+02:00")
|
||||||
|
assertThat(formatGmtOffset(-300)).isEqualTo("GMT-05:00")
|
||||||
|
// India is +05:30 — a whole-hour assumption would render this wrong.
|
||||||
|
assertThat(formatGmtOffset(330)).isEqualTo("GMT+05:30")
|
||||||
|
assertThat(formatGmtOffset(-210)).isEqualTo("GMT-03:30")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class AgendaTimeLabelTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
|
||||||
|
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
|
||||||
|
LocalDateTime(y, mo, d, h, min).toInstant(zone)
|
||||||
|
|
||||||
|
private fun event(start: Instant, end: Instant, isAllDay: Boolean = false) = EventInstance(
|
||||||
|
instanceId = 1,
|
||||||
|
eventId = 1,
|
||||||
|
calendarId = 1,
|
||||||
|
title = "e",
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
color = 0,
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun labelOn(y: Int, mo: Int, d: Int, event: EventInstance) =
|
||||||
|
agendaTimeLabel(event, LocalDate(y, mo, d), zone)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single-day timed event is a start-end range`() {
|
||||||
|
val e = event(at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
|
||||||
|
|
||||||
|
assertThat(labelOn(2026, 7, 2, e))
|
||||||
|
.isEqualTo(AgendaTimeLabel.Range(e.start, e.end))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single-day all-day event is all-day`() {
|
||||||
|
val e = event(at(2026, 7, 2), at(2026, 7, 3), isAllDay = true)
|
||||||
|
|
||||||
|
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day timed event names the start, middle, and end days`() {
|
||||||
|
val e = event(at(2026, 7, 1, 14, 0), at(2026, 7, 4, 10, 0))
|
||||||
|
|
||||||
|
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.Starts(e.start))
|
||||||
|
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||||
|
assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||||
|
assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day all-day event is all-day on every day`() {
|
||||||
|
val e = event(at(2026, 7, 2), at(2026, 7, 5), isAllDay = true)
|
||||||
|
|
||||||
|
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||||
|
assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||||
|
assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event begun before the shown day is not labelled as starting`() {
|
||||||
|
// Runs 29 Jun 09:00 → 2 Jul 09:00; on 1 Jul it is mid-span, on 2 Jul it ends.
|
||||||
|
val e = event(at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
|
||||||
|
|
||||||
|
assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay)
|
||||||
|
assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class GroupAgendaDaysTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
private val anchor = LocalDate(2026, 7, 1)
|
||||||
|
// A wide window so clamping to the window end is out of the way unless tested.
|
||||||
|
private val windowEnd = LocalDate(2026, 7, 31)
|
||||||
|
|
||||||
|
private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant =
|
||||||
|
LocalDateTime(y, mo, d, h, min).toInstant(zone)
|
||||||
|
|
||||||
|
private fun event(
|
||||||
|
id: Long,
|
||||||
|
title: String,
|
||||||
|
start: Instant,
|
||||||
|
end: Instant,
|
||||||
|
isAllDay: Boolean = false,
|
||||||
|
) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1,
|
||||||
|
title = title,
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
color = 0,
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun days(instances: List<EventInstance>) =
|
||||||
|
groupAgendaDays(anchor, windowEnd, instances, zone)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a timed multi-day event lists on every day it spans`() {
|
||||||
|
val e = event(1, "trip", at(2026, 7, 1, 14, 0), at(2026, 7, 3, 10, 0))
|
||||||
|
|
||||||
|
val result = days(listOf(e))
|
||||||
|
|
||||||
|
assertThat(result.map { it.date }).containsExactly(
|
||||||
|
LocalDate(2026, 7, 1),
|
||||||
|
LocalDate(2026, 7, 2),
|
||||||
|
LocalDate(2026, 7, 3),
|
||||||
|
).inOrder()
|
||||||
|
result.forEach { assertThat(it.events).containsExactly(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day multi-day event stops before its exclusive next-midnight end`() {
|
||||||
|
// 1–3 July inclusive: end is the exclusive midnight opening 4 July.
|
||||||
|
val e = event(1, "holiday", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true)
|
||||||
|
|
||||||
|
val result = days(listOf(e))
|
||||||
|
|
||||||
|
assertThat(result.map { it.date }).containsExactly(
|
||||||
|
LocalDate(2026, 7, 1),
|
||||||
|
LocalDate(2026, 7, 2),
|
||||||
|
LocalDate(2026, 7, 3),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single-day event lists exactly once`() {
|
||||||
|
val e = event(1, "lunch", at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0))
|
||||||
|
|
||||||
|
assertThat(days(listOf(e)).map { it.date })
|
||||||
|
.containsExactly(LocalDate(2026, 7, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event ending exactly at midnight does not reach the next day`() {
|
||||||
|
val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0))
|
||||||
|
|
||||||
|
assertThat(days(listOf(e)).map { it.date })
|
||||||
|
.containsExactly(LocalDate(2026, 7, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event begun before the anchor is clamped to the anchor day`() {
|
||||||
|
val e = event(1, "ongoing", at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0))
|
||||||
|
|
||||||
|
assertThat(days(listOf(e)).map { it.date }).containsExactly(
|
||||||
|
LocalDate(2026, 7, 1),
|
||||||
|
LocalDate(2026, 7, 2),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event running past the window is clamped to the last visible day`() {
|
||||||
|
val narrowEnd = LocalDate(2026, 7, 2)
|
||||||
|
val e = event(1, "long", at(2026, 7, 1, 9, 0), at(2026, 7, 5, 9, 0))
|
||||||
|
|
||||||
|
val result = groupAgendaDays(anchor, narrowEnd, listOf(e), zone)
|
||||||
|
|
||||||
|
assertThat(result.map { it.date }).containsExactly(
|
||||||
|
LocalDate(2026, 7, 1),
|
||||||
|
LocalDate(2026, 7, 2),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
// All-day events are stored at UTC midnights; resolving them in a device
|
||||||
|
// zone east of UTC would leak them onto the following day (see spanLastDay).
|
||||||
|
private val berlin = TimeZone.of("Europe/Berlin")
|
||||||
|
|
||||||
|
private fun utcMidnight(y: Int, mo: Int, d: Int): Instant =
|
||||||
|
LocalDateTime(y, mo, d, 0, 0).toInstant(TimeZone.UTC)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single-day all-day event does not leak onto the next day in an eastern zone`() {
|
||||||
|
val e = event(1, "birthday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 3), isAllDay = true)
|
||||||
|
|
||||||
|
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
|
||||||
|
|
||||||
|
assertThat(result.map { it.date }).containsExactly(LocalDate(2026, 7, 2))
|
||||||
|
assertThat(e.spansMultipleDays(berlin)).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day multi-day event spans its true days in an eastern zone`() {
|
||||||
|
// 2–4 July inclusive: exclusive end is the UTC midnight opening 5 July.
|
||||||
|
val e = event(1, "holiday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 5), isAllDay = true)
|
||||||
|
|
||||||
|
val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin)
|
||||||
|
|
||||||
|
assertThat(result.map { it.date }).containsExactly(
|
||||||
|
LocalDate(2026, 7, 2),
|
||||||
|
LocalDate(2026, 7, 3),
|
||||||
|
LocalDate(2026, 7, 4),
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `yesterday's all-day event does not surface under the anchor day in an eastern zone`() {
|
||||||
|
// The agenda window starts at the anchor's local midnight, which in Berlin is
|
||||||
|
// 22:00 UTC the day before — so an all-day event that ended yesterday (whose
|
||||||
|
// exclusive UTC-midnight end sits at that instant) is still returned by the
|
||||||
|
// provider. It must not be clamped onto the anchor's "today" section (#65).
|
||||||
|
val jul19 = LocalDate(2026, 7, 19)
|
||||||
|
val e = event(1, "birthday", utcMidnight(2026, 7, 18), utcMidnight(2026, 7, 19), isAllDay = true)
|
||||||
|
|
||||||
|
val result = groupAgendaDays(jul19, LocalDate(2026, 8, 18), listOf(e), berlin)
|
||||||
|
|
||||||
|
assertThat(result).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an all-day event that falls entirely past the window end is dropped`() {
|
||||||
|
// The same guard, at the far edge: an instance whose true days are all beyond
|
||||||
|
// windowEnd occupies no visible day and must not be clamped onto the last one.
|
||||||
|
val e = event(1, "birthday", utcMidnight(2026, 7, 3), utcMidnight(2026, 7, 4), isAllDay = true)
|
||||||
|
|
||||||
|
val result = groupAgendaDays(anchor, LocalDate(2026, 7, 2), listOf(e), berlin)
|
||||||
|
|
||||||
|
assertThat(result).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `within a day all-day events sort before timed ones`() {
|
||||||
|
val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true)
|
||||||
|
val timed = event(2, "call", at(2026, 7, 1, 9, 0), at(2026, 7, 1, 10, 0))
|
||||||
|
|
||||||
|
val dayOne = days(listOf(timed, allDay)).first { it.date == anchor }
|
||||||
|
|
||||||
|
assertThat(dayOne.events).containsExactly(allDay, timed).inOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The read side of the UNTIL round-trip. `SimpleRecurrence.toRRule` deliberately
|
||||||
|
* writes the end of the chosen *local* day expressed in UTC (see its docs — the
|
||||||
|
* provider applies UNTIL coarsely), so displaying it means converting back into
|
||||||
|
* the device zone first. Reading the leading digits raw showed the wrong day for
|
||||||
|
* zones behind UTC.
|
||||||
|
*/
|
||||||
|
class RecurrenceTextTest {
|
||||||
|
|
||||||
|
private val berlin = ZoneId.of("Europe/Berlin")
|
||||||
|
private val losAngeles = ZoneId.of("America/Los_Angeles")
|
||||||
|
private val utc = ZoneId.of("UTC")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `UTC form converts back to the picked day west of UTC`() {
|
||||||
|
// toRRule("2026-12-31", America/Los_Angeles) → 23:59:59 local = 07:59:59Z
|
||||||
|
// on 1 Jan. Reading the digits raw would show 1 Jan 2027.
|
||||||
|
assertThat(untilLocalDate("20270101T075959Z", losAngeles))
|
||||||
|
.isEqualTo(LocalDate.of(2026, 12, 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `UTC form converts back to the picked day east of UTC`() {
|
||||||
|
// Berlin in December is UTC+1: 23:59:59 local = 22:59:59Z the same day.
|
||||||
|
assertThat(untilLocalDate("20261231T225959Z", berlin))
|
||||||
|
.isEqualTo(LocalDate.of(2026, 12, 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `UTC form is unchanged at UTC itself`() {
|
||||||
|
assertThat(untilLocalDate("20261231T235959Z", utc))
|
||||||
|
.isEqualTo(LocalDate.of(2026, 12, 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `summer offset is honoured, not a fixed one`() {
|
||||||
|
// Berlin in July is UTC+2, so the same local end-of-day lands at 21:59:59Z.
|
||||||
|
assertThat(untilLocalDate("20260801T215959Z", berlin))
|
||||||
|
.isEqualTo(LocalDate.of(2026, 8, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `date-only form is already local and passes through`() {
|
||||||
|
assertThat(untilLocalDate("20261231", losAngeles))
|
||||||
|
.isEqualTo(LocalDate.of(2026, 12, 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `floating date-time form uses its date as-is`() {
|
||||||
|
// No trailing Z, so it isn't an instant — no conversion may be applied.
|
||||||
|
assertThat(untilLocalDate("20261231T235959", losAngeles))
|
||||||
|
.isEqualTo(LocalDate.of(2026, 12, 31))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage returns null rather than throwing`() {
|
||||||
|
assertThat(untilLocalDate("", berlin)).isNull()
|
||||||
|
assertThat(untilLocalDate("nonsense", berlin)).isNull()
|
||||||
|
assertThat(untilLocalDate("2026", berlin)).isNull()
|
||||||
|
assertThat(untilLocalDate("20261340", berlin)).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What makes the continuous style's month blocks self-contained: a boundary week
|
||||||
|
* keeps its seven columns but carries only the block's own month.
|
||||||
|
*/
|
||||||
|
class ClipWeekToMonthTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
private val jul26 = YearMonth(2026, Month.JULY)
|
||||||
|
|
||||||
|
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "A",
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
color = 0xFF445566.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun timed(date: LocalDate, hour: Int, id: Long = 2L) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "T",
|
||||||
|
start = date.atTime(hour, 0).toInstant(zone),
|
||||||
|
end = date.atTime(hour + 1, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** July 2026 starts on a Wednesday, so its first Monday-anchored row is Jun 29 – Jul 5. */
|
||||||
|
private fun firstRowOfJuly(events: List<EventInstance>) =
|
||||||
|
clipWeekToMonth(
|
||||||
|
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone).first(),
|
||||||
|
jul26,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the row keeps all seven days so the columns don't shift`() {
|
||||||
|
val week = firstRowOfJuly(emptyList())
|
||||||
|
assertThat(week.days).hasSize(7)
|
||||||
|
assertThat(week.days.first()).isEqualTo(LocalDate(2026, 6, 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the neighbour month's events are dropped`() {
|
||||||
|
val june = timed(LocalDate(2026, 6, 30), 9)
|
||||||
|
val july = timed(LocalDate(2026, 7, 1), 9, id = 3L)
|
||||||
|
val week = firstRowOfJuly(listOf(june, july))
|
||||||
|
|
||||||
|
assertThat(week.timedByDay.keys).doesNotContain(LocalDate(2026, 6, 30))
|
||||||
|
assertThat(week.timedByDay[LocalDate(2026, 7, 1)]).containsExactly(july)
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 6, 30)]).isNull()
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 7, 1)]).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar reaching in from the previous month is cut back to the 1st`() {
|
||||||
|
// Jun 29 – Jul 2: columns 0..3 unclipped, 2..3 once July owns the row.
|
||||||
|
val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 7, 2))))
|
||||||
|
|
||||||
|
val span = week.spans.single()
|
||||||
|
assertThat(span.startCol).isEqualTo(2) // Wednesday the 1st
|
||||||
|
assertThat(span.endCol).isEqualTo(3)
|
||||||
|
// Flat cap on the cut side: the event continues out of this block.
|
||||||
|
assertThat(span.continuesLeft).isTrue()
|
||||||
|
assertThat(span.continuesRight).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar living entirely in the neighbour month disappears`() {
|
||||||
|
val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 6, 30))))
|
||||||
|
assertThat(week.spans).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar reaching out into the next month is cut at the last day`() {
|
||||||
|
// Jul 29 – Aug 2 sits in July's final row (Jul 27 – Aug 2).
|
||||||
|
val weeks = layoutMonthWeeks(
|
||||||
|
jul26,
|
||||||
|
DayOfWeek.MONDAY,
|
||||||
|
listOf(allDay(LocalDate(2026, 7, 29), LocalDate(2026, 8, 2))),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
val span = clipWeekToMonth(weeks.last(), jul26).spans.single()
|
||||||
|
assertThat(span.startCol).isEqualTo(2) // Wednesday the 29th
|
||||||
|
assertThat(span.endCol).isEqualTo(4) // Friday the 31st
|
||||||
|
assertThat(span.continuesRight).isTrue()
|
||||||
|
assertThat(span.continuesLeft).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a row wholly inside the month is untouched`() {
|
||||||
|
val mid = layoutMonthWeeks(
|
||||||
|
jul26,
|
||||||
|
DayOfWeek.MONDAY,
|
||||||
|
listOf(timed(LocalDate(2026, 7, 8), 9), allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9))),
|
||||||
|
zone,
|
||||||
|
)[1]
|
||||||
|
assertThat(clipWeekToMonth(mid, jul26)).isEqualTo(mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The continuous grid's coordinate space. Every block's identity — and the
|
||||||
|
* LazyColumn items it maps to — hangs off this arithmetic, so it gets its own
|
||||||
|
* tests rather than being exercised only through the UI.
|
||||||
|
*/
|
||||||
|
class ContinuousMonthIndexTest {
|
||||||
|
|
||||||
|
private val jun26 = YearMonth(2026, Month.JUNE)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `consecutive months are consecutive indices`() {
|
||||||
|
val jun = monthIndexOf(jun26)
|
||||||
|
assertThat(monthIndexOf(YearMonth(2026, Month.JULY))).isEqualTo(jun + 1)
|
||||||
|
assertThat(monthIndexOf(YearMonth(2026, Month.MAY))).isEqualTo(jun - 1)
|
||||||
|
// And across the year boundary.
|
||||||
|
assertThat(monthIndexOf(YearMonth(2027, Month.JANUARY)))
|
||||||
|
.isEqualTo(monthIndexOf(YearMonth(2026, Month.DECEMBER)) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `index round-trips back to its month`() {
|
||||||
|
listOf(
|
||||||
|
YearMonth(1900, Month.JANUARY),
|
||||||
|
jun26,
|
||||||
|
YearMonth(2026, Month.DECEMBER),
|
||||||
|
YearMonth(2100, Month.DECEMBER),
|
||||||
|
).forEach { ym ->
|
||||||
|
assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `indices are non-negative and span 1900 through 2100`() {
|
||||||
|
assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0)
|
||||||
|
assertThat(monthIndexOf(jun26)).isGreaterThan(0)
|
||||||
|
assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER)))
|
||||||
|
.isEqualTo(continuousMonthCount() - 1)
|
||||||
|
assertThat(continuousMonthCount()).isEqualTo(201 * 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a month maps to its header item and back`() {
|
||||||
|
val index = monthIndexOf(jun26)
|
||||||
|
val item = itemIndexForMonth(index)
|
||||||
|
// Both of a month's items — its sticky header and the block under it —
|
||||||
|
// resolve back to the same month, so a title read off the first visible
|
||||||
|
// item is right wherever the viewport sits inside the block.
|
||||||
|
assertThat(monthIndexForItem(item)).isEqualTo(index)
|
||||||
|
assertThat(monthIndexForItem(item + 1)).isEqualTo(index)
|
||||||
|
assertThat(monthIndexForItem(item + 2)).isEqualTo(index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `week rows follow the month's shape and its week start`() {
|
||||||
|
// June 2026 starts on a Monday: 30 days → 5 rows either way.
|
||||||
|
assertThat(weekRowsInMonth(jun26, DayOfWeek.MONDAY)).isEqualTo(5)
|
||||||
|
assertThat(weekRowsInMonth(jun26, DayOfWeek.SUNDAY)).isEqualTo(5)
|
||||||
|
// February 2026 starts on a Sunday: a Sunday-anchored grid fits it in 4
|
||||||
|
// rows, a Monday-anchored one needs 5 — the block's height moves with the
|
||||||
|
// preference even though its position in the list doesn't.
|
||||||
|
val feb26 = YearMonth(2026, Month.FEBRUARY)
|
||||||
|
assertThat(weekRowsInMonth(feb26, DayOfWeek.SUNDAY)).isEqualTo(4)
|
||||||
|
assertThat(weekRowsInMonth(feb26, DayOfWeek.MONDAY)).isEqualTo(5)
|
||||||
|
// February 2021 starts on a Monday and is 28 days → exactly 4.
|
||||||
|
assertThat(weekRowsInMonth(YearMonth(2021, Month.FEBRUARY), DayOfWeek.MONDAY))
|
||||||
|
.isEqualTo(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the placeholder block is the size the loaded one will be`() {
|
||||||
|
// Otherwise scrolling across an unloaded month jumps when its data lands.
|
||||||
|
(0 until 24).forEach { offset ->
|
||||||
|
val ym = yearMonthForIndex(monthIndexOf(jun26) + offset)
|
||||||
|
DayOfWeek.entries.forEach { ws ->
|
||||||
|
assertThat(weekRowsInMonth(ym, ws))
|
||||||
|
.isEqualTo(layoutMonthWeeks(ym, ws, emptyList(), TimeZone.UTC).size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a window is clamped to the months that exist`() {
|
||||||
|
// The regression this guards: an empty visible-items list reported as
|
||||||
|
// "month 0", whose padded window ran to -4 — and yearMonthForIndex has
|
||||||
|
// no month for that, so the whole grid failed with "couldn't read".
|
||||||
|
assertThat(clampMonthWindow(-4..4)).isEqualTo(0..4)
|
||||||
|
val last = continuousMonthCount() - 1
|
||||||
|
assertThat(clampMonthWindow((last - 2)..(last + 4))).isEqualTo((last - 2)..last)
|
||||||
|
assertThat(clampMonthWindow(10..20)).isEqualTo(10..20)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every index a clamped window can hold resolves to a month`() {
|
||||||
|
listOf(clampMonthWindow(-4..4), clampMonthWindow((continuousMonthCount() - 1)..(continuousMonthCount() + 3)))
|
||||||
|
.forEach { window ->
|
||||||
|
window.forEach { index -> yearMonthForIndex(index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the dense week window covers every week its months touch`() {
|
||||||
|
// One month-window load has to lay out every Dense row those months
|
||||||
|
// appear in, boundary weeks included, or rows would flicker as
|
||||||
|
// placeholders while the data for them is already in hand.
|
||||||
|
DayOfWeek.entries.forEach { ws ->
|
||||||
|
val window = monthIndexOf(jun26)..monthIndexOf(YearMonth(2026, Month.AUGUST))
|
||||||
|
val weeks = weekWindowFor(window, ws)
|
||||||
|
window.forEach { index ->
|
||||||
|
val ym = yearMonthForIndex(index)
|
||||||
|
val first = weekIndexOf(firstOfMonth(ym), ws)
|
||||||
|
val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws)
|
||||||
|
assertThat(weeks.contains(first)).isTrue()
|
||||||
|
assertThat(weeks.contains(last)).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a dense row reports the month that owns most of it`() {
|
||||||
|
// June 29 – July 5, 2026 (Monday-anchored): four of its days are July's,
|
||||||
|
// so the window follows July rather than snapping back to June.
|
||||||
|
val boundary = weekIndexOf(LocalDate(2026, 6, 29), DayOfWeek.MONDAY)
|
||||||
|
assertThat(monthIndexForWeek(boundary, DayOfWeek.MONDAY))
|
||||||
|
.isEqualTo(monthIndexOf(YearMonth(2026, Month.JULY)))
|
||||||
|
// A row wholly inside June reports June.
|
||||||
|
assertThat(monthIndexForWeek(weekIndexOf(LocalDate(2026, 6, 15), DayOfWeek.MONDAY), DayOfWeek.MONDAY))
|
||||||
|
.isEqualTo(monthIndexOf(jun26))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every day of a dense week shares one index`() {
|
||||||
|
val mon = LocalDate(2026, 6, 8)
|
||||||
|
val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) }
|
||||||
|
assertThat(indices.toSet()).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dense week indices round-trip and stay inside the list`() {
|
||||||
|
val wed = LocalDate(2026, 6, 10)
|
||||||
|
DayOfWeek.entries.forEach { ws ->
|
||||||
|
val index = weekIndexOf(wed, ws)
|
||||||
|
assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws))
|
||||||
|
assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0)
|
||||||
|
assertThat(index).isLessThan(continuousWeekCount(ws))
|
||||||
|
assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws))
|
||||||
|
.isLessThan(continuousWeekCount(ws))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `firstOfMonth is the 1st`() {
|
||||||
|
assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a window comfortably around the visible range is kept`() {
|
||||||
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nearing a loaded edge widens the window around the visible range`() {
|
||||||
|
// Within two months of the top edge → reload, padded on both sides.
|
||||||
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2))
|
||||||
|
.isEqualTo(-4..7)
|
||||||
|
|
||||||
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100))
|
||||||
|
.isEqualTo(93..105)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a jump far outside the window reloads around the destination`() {
|
||||||
|
assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501))
|
||||||
|
.isEqualTo(495..506)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the reloaded window always clears the trigger it just crossed`() {
|
||||||
|
// Otherwise every scroll frame would re-trigger a query — and with the
|
||||||
|
// window growing from a pad of 1, this has to hold at every rung of the
|
||||||
|
// ladder, not just the widest one.
|
||||||
|
(1..8).forEach { pad ->
|
||||||
|
val loaded = (10 - pad)..(10 + pad)
|
||||||
|
val past = 10 + pad + 1
|
||||||
|
val widened = nextLoadWindow(loaded, past, past, pad)!!
|
||||||
|
assertThat(nextLoadWindow(widened, past, past, pad)).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the reload trigger stays inside the pad`() {
|
||||||
|
// A trigger at or beyond the pad would fire again the instant its own
|
||||||
|
// reload landed, and the window would query forever.
|
||||||
|
(1..8).forEach { pad -> assertThat(edgeForPad(pad)).isLessThan(pad) }
|
||||||
|
// A one-month window has no room for hysteresis: reload only on contact.
|
||||||
|
assertThat(edgeForPad(1)).isEqualTo(0)
|
||||||
|
assertThat(edgeForPad(0)).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the smallest window reloads on crossing rather than nearing its edge`() {
|
||||||
|
// The first frame loads one month either side of today, which leaves no
|
||||||
|
// room to reload *before* the edge: at pad 1 the trigger is contact.
|
||||||
|
// Only momentary — the first completed load widens the pad to 3, which
|
||||||
|
// buys the usual head start back.
|
||||||
|
val initial = 10..12
|
||||||
|
assertThat(nextLoadWindow(initial, firstVisible = 12, lastVisible = 12, pad = 1)).isNull()
|
||||||
|
assertThat(nextLoadWindow(initial, firstVisible = 13, lastVisible = 13, pad = 1))
|
||||||
|
.isEqualTo(12..14)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the split selection follows the month, landing on today when it's there`() {
|
||||||
|
val today = LocalDate(2026, 6, 10)
|
||||||
|
assertThat(selectionForMonth(jun26, today)).isEqualTo(today)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the split selection falls to the 1st of any other month`() {
|
||||||
|
val today = LocalDate(2026, 6, 10)
|
||||||
|
assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today))
|
||||||
|
.isEqualTo(LocalDate(2026, 7, 1))
|
||||||
|
assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today))
|
||||||
|
.isEqualTo(LocalDate(2025, 6, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.minus
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [DayIndex] exists purely to make the scrolling styles cheap, so what it owes
|
||||||
|
* is that cheapness changes *nothing*: a row laid out from the index must be the
|
||||||
|
* row that would have come from scanning every instance.
|
||||||
|
*
|
||||||
|
* Run in a zone east of UTC, where the all-day anchoring and the day boundaries
|
||||||
|
* disagree — the case that has bitten this codebase before (#65).
|
||||||
|
*/
|
||||||
|
class DayIndexTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.of("Europe/Berlin")
|
||||||
|
private val mon = LocalDate(2026, 6, 8)
|
||||||
|
private val week = (0..6).map { mon.plus(it, DateTimeUnit.DAY) }
|
||||||
|
|
||||||
|
private var nextId = 0L
|
||||||
|
|
||||||
|
private fun timed(from: LocalDate, startHour: Int, to: LocalDate, endHour: Int): EventInstance {
|
||||||
|
val id = ++nextId
|
||||||
|
return EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "T$id",
|
||||||
|
start = from.atTime(startHour, 0).toInstant(zone),
|
||||||
|
end = to.atTime(endHour, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allDay(from: LocalDate, toInclusive: LocalDate): EventInstance {
|
||||||
|
val id = ++nextId
|
||||||
|
return EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "A$id",
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
color = 0xFF445566.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every shape the layout distinguishes, plus the boundary cases around them. */
|
||||||
|
private fun awkwardEvents(): List<EventInstance> = listOf(
|
||||||
|
timed(mon, 9, mon, 10),
|
||||||
|
// Crosses midnight into the next day.
|
||||||
|
timed(mon.plus(1, DateTimeUnit.DAY), 23, mon.plus(2, DateTimeUnit.DAY), 1),
|
||||||
|
// Ends exactly at midnight: covers its own day, not the next.
|
||||||
|
timed(mon.plus(3, DateTimeUnit.DAY), 22, mon.plus(4, DateTimeUnit.DAY), 0),
|
||||||
|
// Zero length, at midnight: covers nothing at all.
|
||||||
|
timed(mon.plus(4, DateTimeUnit.DAY), 0, mon.plus(4, DateTimeUnit.DAY), 0),
|
||||||
|
allDay(mon.plus(2, DateTimeUnit.DAY), mon.plus(2, DateTimeUnit.DAY)),
|
||||||
|
// Runs in from before the row and out past its end.
|
||||||
|
allDay(mon.minus(3, DateTimeUnit.DAY), mon.plus(9, DateTimeUnit.DAY)),
|
||||||
|
// Wholly outside the row, both sides.
|
||||||
|
timed(mon.minus(10, DateTimeUnit.DAY), 9, mon.minus(10, DateTimeUnit.DAY), 10),
|
||||||
|
allDay(mon.plus(20, DateTimeUnit.DAY), mon.plus(21, DateTimeUnit.DAY)),
|
||||||
|
// Two on one day, to pin the ordering the index hands back.
|
||||||
|
timed(mon.plus(5, DateTimeUnit.DAY), 8, mon.plus(5, DateTimeUnit.DAY), 9),
|
||||||
|
timed(mon.plus(5, DateTimeUnit.DAY), 14, mon.plus(5, DateTimeUnit.DAY), 15),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a row from the index is the row from a full scan`() {
|
||||||
|
val events = awkwardEvents()
|
||||||
|
val scanned = layoutCalendarWeek(week, events, zone)
|
||||||
|
val indexed = layoutCalendarWeek(week, DayIndex(events, zone).eventsOn(week), zone)
|
||||||
|
assertThat(indexed).isEqualTo(scanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a whole month agrees, row for row`() {
|
||||||
|
val events = awkwardEvents()
|
||||||
|
val jun = YearMonth(2026, Month.JUNE)
|
||||||
|
DayOfWeek.entries.forEach { ws ->
|
||||||
|
assertThat(layoutMonthWeeks(jun, ws, DayIndex(events, zone), zone))
|
||||||
|
.isEqualTo(layoutMonthWeeks(jun, ws, events, zone))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an event covering several of a row's days is listed once`() {
|
||||||
|
// It is bucketed on each date it covers, so the row would otherwise see
|
||||||
|
// it repeatedly and lay out a lane per copy.
|
||||||
|
val long = allDay(mon, mon.plus(4, DateTimeUnit.DAY))
|
||||||
|
val events = DayIndex(listOf(long), zone).eventsOn(week)
|
||||||
|
assertThat(events).containsExactly(long)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `days no event touches come back empty`() {
|
||||||
|
val index = DayIndex(listOf(timed(mon, 9, mon, 10)), zone)
|
||||||
|
assertThat(index.eventsOn(listOf(mon.plus(1, DateTimeUnit.DAY)))).isEmpty()
|
||||||
|
assertThat(index.eventsOn(emptyList())).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty calendar indexes to nothing`() {
|
||||||
|
assertThat(DayIndex(emptyList(), zone).eventsOn(week)).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.Month
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What lets the split style's dots morph into the paged style's bars (#53): both
|
||||||
|
* read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event.
|
||||||
|
*/
|
||||||
|
class LaneEventsTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
private val jul26 = YearMonth(2026, Month.JULY)
|
||||||
|
|
||||||
|
/** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */
|
||||||
|
private fun rowOfJuly6(events: List<EventInstance>) =
|
||||||
|
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
|
||||||
|
|
||||||
|
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) =
|
||||||
|
EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "A$id",
|
||||||
|
start = from.atTime(0, 0).toInstant(zone),
|
||||||
|
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
|
||||||
|
isAllDay = true,
|
||||||
|
color = color,
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = "T$id",
|
||||||
|
start = date.atTime(hour, 0).toInstant(zone),
|
||||||
|
end = date.atTime(hour + 1, 0).toInstant(zone),
|
||||||
|
isAllDay = false,
|
||||||
|
color = color,
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bar keeps its lane and the day's timed events fill what's left`() {
|
||||||
|
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||||
|
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
|
||||||
|
val week = rowOfJuly6(listOf(bar, meeting))
|
||||||
|
|
||||||
|
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
||||||
|
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
|
||||||
|
.containsExactly(bar, meeting)
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a day the bar misses seats its own events from lane zero`() {
|
||||||
|
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||||
|
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
|
||||||
|
val week = rowOfJuly6(listOf(bar, monday))
|
||||||
|
|
||||||
|
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||||
|
.containsExactly(monday)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day bar is seated on every day it covers`() {
|
||||||
|
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||||
|
val week = rowOfJuly6(listOf(bar))
|
||||||
|
|
||||||
|
(1..3).forEach { col ->
|
||||||
|
val day = LocalDate(2026, 7, 6 + col)
|
||||||
|
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
|
||||||
|
}
|
||||||
|
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
|
||||||
|
@Test
|
||||||
|
fun `events sharing a colour each keep their own lane`() {
|
||||||
|
val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED)
|
||||||
|
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
|
||||||
|
val week = rowOfJuly6(listOf(first, second))
|
||||||
|
|
||||||
|
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||||
|
.containsExactly(first, second)
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `seating stops at the cap and leaves the rest to the overflow count`() {
|
||||||
|
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
|
||||||
|
val week = rowOfJuly6(events)
|
||||||
|
|
||||||
|
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||||
|
assertThat(seated).hasSize(3)
|
||||||
|
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bar parked below the cap is out of view, so it takes no dot with it. */
|
||||||
|
@Test
|
||||||
|
fun `a bar beyond the cap is left out`() {
|
||||||
|
val bars = (0 until 4).map {
|
||||||
|
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
|
||||||
|
}
|
||||||
|
val week = rowOfJuly6(bars)
|
||||||
|
|
||||||
|
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||||
|
assertThat(seated).hasSize(3)
|
||||||
|
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
|
||||||
|
.containsNoneIn(seated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BLUE = 0xFF3366CC.toInt()
|
||||||
|
const val RED = 0xFFCC3333.toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.YearMonth
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class MonthLayoutTest {
|
||||||
|
|
||||||
|
private val zone = TimeZone.UTC
|
||||||
|
|
||||||
|
// 2026-06-01 is a Monday, so a Monday-anchored June grid starts on the 1st.
|
||||||
|
private val jun = YearMonth(2026, kotlinx.datetime.Month.JUNE)
|
||||||
|
private val jun1 = LocalDate(2026, 6, 1)
|
||||||
|
private val jun8 = LocalDate(2026, 6, 8)
|
||||||
|
private val weekOf8th = (0..6).map { jun8.plus(it, DateTimeUnit.DAY) }
|
||||||
|
|
||||||
|
private fun at(date: LocalDate, h: Int, m: Int = 0): Instant =
|
||||||
|
date.atTime(h, m).toInstant(zone)
|
||||||
|
|
||||||
|
private fun timed(
|
||||||
|
date: LocalDate,
|
||||||
|
startHour: Int,
|
||||||
|
endHour: Int,
|
||||||
|
id: Long = 1L,
|
||||||
|
title: String = "E",
|
||||||
|
) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = title,
|
||||||
|
start = at(date, startHour),
|
||||||
|
end = at(date, endHour),
|
||||||
|
isAllDay = false,
|
||||||
|
color = 0xFF112233.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** All-day events live at UTC midnights with an *exclusive* end. */
|
||||||
|
private fun allDay(
|
||||||
|
from: LocalDate,
|
||||||
|
toInclusive: LocalDate = from,
|
||||||
|
id: Long = 100L,
|
||||||
|
title: String = "A",
|
||||||
|
) = EventInstance(
|
||||||
|
instanceId = id,
|
||||||
|
eventId = id,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = title,
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
color = 0xFF445566.toInt(),
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `startOfGridWeek snaps back to the configured week start`() {
|
||||||
|
val wed = LocalDate(2026, 6, 10)
|
||||||
|
assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
||||||
|
assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8)
|
||||||
|
// A Sunday-anchored week containing the 10th starts on the 7th.
|
||||||
|
assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `monthGridRange always covers 42 days from the grid start`() {
|
||||||
|
val range = monthGridRange(jun, DayOfWeek.MONDAY, zone)
|
||||||
|
assertThat(range.start).isEqualTo(at(jun1, 0))
|
||||||
|
// 42 days inclusive → the last second of 2026-07-12.
|
||||||
|
assertThat(range.endInclusive)
|
||||||
|
.isEqualTo(LocalDate(2026, 7, 12).atTime(23, 59, 59).toInstant(zone))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `week count follows the month's shape rather than a fixed six rows`() {
|
||||||
|
// June 2026: starts Monday, 30 days → 5 rows.
|
||||||
|
assertThat(layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(5)
|
||||||
|
// August 2026: starts Saturday, 31 days → spills to 6 rows.
|
||||||
|
val aug = YearMonth(2026, kotlinx.datetime.Month.AUGUST)
|
||||||
|
assertThat(layoutMonthWeeks(aug, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(6)
|
||||||
|
// February 2021: starts Monday, 28 days → exactly 4 rows.
|
||||||
|
val feb = YearMonth(2021, kotlinx.datetime.Month.FEBRUARY)
|
||||||
|
assertThat(layoutMonthWeeks(feb, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `layoutMonthWeeks rows are contiguous and seven days wide`() {
|
||||||
|
val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)
|
||||||
|
assertThat(weeks.first().days.first()).isEqualTo(jun1)
|
||||||
|
weeks.forEach { assertThat(it.days).hasSize(7) }
|
||||||
|
val allDays = weeks.flatMap { it.days }
|
||||||
|
allDays.zipWithNext { a, b -> assertThat(b).isEqualTo(a.plus(1, DateTimeUnit.DAY)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-day event becomes one span across its columns`() {
|
||||||
|
val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12))
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
|
||||||
|
|
||||||
|
assertThat(week.spans).hasSize(1)
|
||||||
|
val span = week.spans.single()
|
||||||
|
assertThat(span.startCol).isEqualTo(2) // Wednesday the 10th
|
||||||
|
assertThat(span.endCol).isEqualTo(4) // Friday the 12th
|
||||||
|
assertThat(span.lane).isEqualTo(0)
|
||||||
|
assertThat(span.continuesLeft).isFalse()
|
||||||
|
assertThat(span.continuesRight).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a span running past the row end is flagged as continuing`() {
|
||||||
|
// Saturday the 13th through Tuesday the 16th straddles the row boundary.
|
||||||
|
val ev = allDay(LocalDate(2026, 6, 13), LocalDate(2026, 6, 16))
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone)
|
||||||
|
|
||||||
|
val span = week.spans.single()
|
||||||
|
assertThat(span.startCol).isEqualTo(5)
|
||||||
|
assertThat(span.endCol).isEqualTo(6)
|
||||||
|
assertThat(span.continuesLeft).isFalse()
|
||||||
|
assertThat(span.continuesRight).isTrue()
|
||||||
|
|
||||||
|
// The following row picks it up with the flags mirrored.
|
||||||
|
val nextRow = layoutCalendarWeek(
|
||||||
|
weekOf8th.map { it.plus(7, DateTimeUnit.DAY) },
|
||||||
|
listOf(ev),
|
||||||
|
zone,
|
||||||
|
)
|
||||||
|
val tail = nextRow.spans.single()
|
||||||
|
assertThat(tail.startCol).isEqualTo(0)
|
||||||
|
assertThat(tail.endCol).isEqualTo(1)
|
||||||
|
assertThat(tail.continuesLeft).isTrue()
|
||||||
|
assertThat(tail.continuesRight).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overlapping spans are stacked on separate lanes`() {
|
||||||
|
val a = allDay(LocalDate(2026, 6, 9), LocalDate(2026, 6, 11), id = 1L)
|
||||||
|
val b = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 2L)
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(a, b), zone)
|
||||||
|
|
||||||
|
assertThat(week.spans.map { it.lane }).containsExactly(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `single-day timed events stay pills and sort by start`() {
|
||||||
|
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L)
|
||||||
|
val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L)
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(late, early), zone)
|
||||||
|
|
||||||
|
assertThat(week.spans).isEmpty()
|
||||||
|
assertThat(week.timedByDay[LocalDate(2026, 6, 10)]?.map { it.instanceId })
|
||||||
|
.containsExactly(2L, 1L)
|
||||||
|
.inOrder()
|
||||||
|
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `countByDay totals bars and pills on each date`() {
|
||||||
|
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)
|
||||||
|
val pill = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L)
|
||||||
|
val week = layoutCalendarWeek(weekOf8th, listOf(span, pill), zone)
|
||||||
|
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 6, 10)]).isEqualTo(2)
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 6, 11)]).isEqualTo(1)
|
||||||
|
assertThat(week.countByDay[LocalDate(2026, 6, 13)]).isEqualTo(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `layoutMonthWeeks agrees with laying each row out on its own`() {
|
||||||
|
val events = listOf(
|
||||||
|
allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L),
|
||||||
|
timed(LocalDate(2026, 6, 18), 9, 10, id = 2L),
|
||||||
|
)
|
||||||
|
val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, events, zone)
|
||||||
|
|
||||||
|
weeks.forEach { row ->
|
||||||
|
assertThat(row).isEqualTo(layoutCalendarWeek(row.days, events, zone))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instancesByDay puts all-day events first then orders by start`() {
|
||||||
|
val allDayEv = allDay(LocalDate(2026, 6, 10), id = 1L)
|
||||||
|
val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 2L)
|
||||||
|
val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 3L)
|
||||||
|
|
||||||
|
val byDay = instancesByDay(weekOf8th, listOf(late, early, allDayEv), zone)
|
||||||
|
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 10)).map { it.instanceId })
|
||||||
|
.containsExactly(1L, 3L, 2L)
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instancesByDay repeats a multi-day event on every date it covers`() {
|
||||||
|
val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12))
|
||||||
|
val byDay = instancesByDay(weekOf8th, listOf(ev), zone)
|
||||||
|
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 9))).isEmpty()
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 10))).hasSize(1)
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 11))).hasSize(1)
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 12))).hasSize(1)
|
||||||
|
assertThat(byDay.getValue(LocalDate(2026, 6, 13))).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `instancesByDay covers every date in the grid, empty ones included`() {
|
||||||
|
val byDay = instancesByDay(weekOf8th, emptyList(), zone)
|
||||||
|
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
|
||||||
|
assertThat(byDay.values.flatten()).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class WidgetScaleTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the on-device calibration points map to their tiers`() {
|
||||||
|
// The two sizes measured on-device: the compact widget stays COMPACT (the
|
||||||
|
// baseline, unchanged), the full-width one steps up to LARGE — not XLARGE,
|
||||||
|
// which read as too big on a phone (#51).
|
||||||
|
assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a full-width widget at ordinary height still scales up`() {
|
||||||
|
// The regression the height cap used to cause: widening the widget without
|
||||||
|
// also making it unusually tall is *the* resize #51 reports, and it must
|
||||||
|
// reach the tier its width earned. Three cells tall is about 270dp.
|
||||||
|
assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `width buckets into the four tiers`() {
|
||||||
|
// Tall enough that the height cap never binds, isolating the width rule.
|
||||||
|
val h = 500.dp
|
||||||
|
assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE)
|
||||||
|
assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extra height never raises the tier`() {
|
||||||
|
// Height decides how many rows are visible, not how big they are: a tall,
|
||||||
|
// narrow widget wants more events, not bigger text.
|
||||||
|
assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only a genuinely squashed widget is stepped back down`() {
|
||||||
|
// Same (wide) width, shrinking height. The cap exists to stop oversized type
|
||||||
|
// surviving in a one- or two-row sliver — it must not fire at normal heights.
|
||||||
|
// Heights are gross; the cap works on height minus 60dp of chrome, so the
|
||||||
|
// LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp).
|
||||||
|
val wide = 378.dp
|
||||||
|
assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR)
|
||||||
|
assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
// The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT.
|
||||||
|
assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the tier is monotonic in both axes`() {
|
||||||
|
// Growing a widget must never make its type smaller. Sweeps the whole
|
||||||
|
// plausible range rather than spot-checking, so a future threshold edit
|
||||||
|
// can't accidentally invert a step.
|
||||||
|
val widths = (110..900 step 7).map { it.dp }
|
||||||
|
val heights = (110..900 step 7).map { it.dp }
|
||||||
|
widths.forEach { w ->
|
||||||
|
heights.zipWithNext { shorter, taller ->
|
||||||
|
assertThat(scaleFor(DpSize(w, taller)))
|
||||||
|
.isAtLeast(scaleFor(DpSize(w, shorter)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
heights.forEach { h ->
|
||||||
|
widths.zipWithNext { narrower, wider ->
|
||||||
|
assertThat(scaleFor(DpSize(wider, h)))
|
||||||
|
.isAtLeast(scaleFor(DpSize(narrower, h)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetScale
|
||||||
|
import de.jeanlucmakiola.calendula.widget.scaleFor
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class AgendaScaleTest {
|
||||||
|
|
||||||
|
// --- the "default size is unchanged" regression guard ---------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every default placement width stays COMPACT`() {
|
||||||
|
// Ties the guarantee to the provider XML's declared 3-cell default rather
|
||||||
|
// than to one measured launcher: the whole band a default placement can
|
||||||
|
// land in must bucket to COMPACT, or a freshly placed widget silently
|
||||||
|
// changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND.
|
||||||
|
val band = AGENDA_DEFAULT_WIDTH_BAND
|
||||||
|
var w = band.start
|
||||||
|
while (w <= band.endInclusive) {
|
||||||
|
assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT)
|
||||||
|
w += 1.dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `COMPACT metrics equal the widget's original constants`() {
|
||||||
|
// If this fails, a default-sized agenda widget no longer looks as it did.
|
||||||
|
val m = metricsFor(WidgetScale.COMPACT)
|
||||||
|
assertThat(m.title).isEqualTo(16.sp)
|
||||||
|
assertThat(m.dayHeader).isEqualTo(13.sp)
|
||||||
|
assertThat(m.eventTitle).isEqualTo(14.sp)
|
||||||
|
assertThat(m.eventTime).isEqualTo(12.sp)
|
||||||
|
assertThat(m.placeholder).isEqualTo(14.sp)
|
||||||
|
assertThat(m.message).isEqualTo(14.sp)
|
||||||
|
assertThat(m.stripeH).isEqualTo(36.dp)
|
||||||
|
assertThat(m.iconImage).isEqualTo(22.dp)
|
||||||
|
assertThat(m.iconBox).isEqualTo(40.dp)
|
||||||
|
assertThat(m.rowVPad).isEqualTo(4.dp)
|
||||||
|
assertThat(m.dayHeaderTopPad).isEqualTo(10.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the derived text indent matches the original hardcoded inset`() {
|
||||||
|
// TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or
|
||||||
|
// day headers and the placeholder line stop aligning with event titles.
|
||||||
|
assertThat(TEXT_INDENT).isEqualTo(19.dp)
|
||||||
|
assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the ramp ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sizes are non-decreasing across the tiers`() {
|
||||||
|
val tiers = WidgetScale.entries.map(::metricsFor)
|
||||||
|
|
||||||
|
tiers.zipWithNext { small, big ->
|
||||||
|
assertThat(big.title.value).isAtLeast(small.title.value)
|
||||||
|
assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value)
|
||||||
|
assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value)
|
||||||
|
assertThat(big.eventTime.value).isAtLeast(small.eventTime.value)
|
||||||
|
assertThat(big.placeholder.value).isAtLeast(small.placeholder.value)
|
||||||
|
assertThat(big.message.value).isAtLeast(small.message.value)
|
||||||
|
assertThat(big.stripeH.value).isAtLeast(small.stripeH.value)
|
||||||
|
assertThat(big.iconImage.value).isAtLeast(small.iconImage.value)
|
||||||
|
assertThat(big.iconBox.value).isAtLeast(small.iconBox.value)
|
||||||
|
assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value)
|
||||||
|
assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the event title keeps its lead over the time line`() {
|
||||||
|
// The secondary line steps more slowly on purpose; if it ever caught up the
|
||||||
|
// row would lose its hierarchy.
|
||||||
|
WidgetScale.entries.map(::metricsFor).forEach { m ->
|
||||||
|
assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no tier grows type more than half again over the baseline`() {
|
||||||
|
// Guards against a future edit turning "more readable" into "absurd".
|
||||||
|
val base = metricsFor(WidgetScale.COMPACT)
|
||||||
|
val top = metricsFor(WidgetScale.XLARGE)
|
||||||
|
assertThat(top.title.value / base.title.value).isLessThan(1.5f)
|
||||||
|
assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- font scale ----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the stripe tracks the system font scale`() {
|
||||||
|
// The stripe is Dp, the text beside it is sp: without this the two diverge
|
||||||
|
// at large accessibility font settings and the stripe under-runs the row.
|
||||||
|
val m = metricsFor(WidgetScale.COMPACT)
|
||||||
|
assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp)
|
||||||
|
assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f)
|
||||||
|
assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scaling for the default font scale changes nothing`() {
|
||||||
|
val m = metricsFor(WidgetScale.LARGE)
|
||||||
|
assertThat(m.scaledForFont(1f)).isSameInstanceAs(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `font scaling leaves the sp sizes alone`() {
|
||||||
|
// Glance already applies the font scale to sp; scaling them here too would
|
||||||
|
// double-count it.
|
||||||
|
val m = metricsFor(WidgetScale.REGULAR)
|
||||||
|
val scaled = m.scaledForFont(1.3f)
|
||||||
|
assertThat(scaled.title).isEqualTo(m.title)
|
||||||
|
assertThat(scaled.eventTitle).isEqualTo(m.eventTitle)
|
||||||
|
assertThat(scaled.rowVPad).isEqualTo(m.rowVPad)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,6 +99,30 @@ on-device — see plan 03):
|
|||||||
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
||||||
- All-day events are normalised to UTC midnights with an exclusive end.
|
- All-day events are normalised to UTC midnights with an exclusive end.
|
||||||
|
|
||||||
|
### Event time zones
|
||||||
|
|
||||||
|
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
|
||||||
|
"the device zone at save time"** — not "no zone". The data layer resolves it in
|
||||||
|
`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary
|
||||||
|
event behaves exactly as it did before the field existed.
|
||||||
|
|
||||||
|
- A non-null value **pins** the event: it keeps tracking that zone's offset
|
||||||
|
across DST no matter where the device is. `toEditForm` only pins when the
|
||||||
|
stored zone differs from the device's, so the optional Time-zone field stays
|
||||||
|
hidden on ordinary events and reveals itself (via `populatedFields`) on
|
||||||
|
foreign-zone ones.
|
||||||
|
- A pinned event is prefilled **in its own zone**, so the form shows the
|
||||||
|
wall-clock the event means rather than the device's rendering of it.
|
||||||
|
- A zone change counts as a **time change** even with the wall-clock untouched
|
||||||
|
(same 09:00 elsewhere is a different instant), so `buildEventUpdateValues`
|
||||||
|
includes it in `timesChanged` and rewrites `DTSTART`.
|
||||||
|
- **All-day events never carry a zone.** They're date-anchored — the UTC
|
||||||
|
midnights above are an anchor, not a location — so the field is withheld from
|
||||||
|
the form entirely and `toWriteTimes` forces `"UTC"` regardless.
|
||||||
|
|
||||||
|
Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and
|
||||||
|
`AllDayReminderEncoding`'s offset (see its KDoc).
|
||||||
|
|
||||||
## Save conflicts
|
## Save conflicts
|
||||||
|
|
||||||
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
|
No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form
|
||||||
|
|||||||
34
fastlane/metadata/android/en-US/changelogs/21401.txt
Normal file
34
fastlane/metadata/android/en-US/changelogs/21401.txt
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
### 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]).
|
||||||
|
|
||||||
74
fastlane/metadata/android/en-US/changelogs/21500.txt
Normal file
74
fastlane/metadata/android/en-US/changelogs/21500.txt
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
### Added
|
||||||
|
- Show raw calendar colours. Calendula normally softens each calendar and event
|
||||||
|
colour toward a theme-fitting pastel so harsh sync colours read well on both
|
||||||
|
light and dark; a new **Soften calendar colours** setting (Settings → Design,
|
||||||
|
on by default) lets you turn that off and paint the exact colours your calendar
|
||||||
|
source publishes — matching DAVx5/CalDAV and other calendar apps. Thanks to
|
||||||
|
@leonp5 for the report ([#36]).
|
||||||
|
- Readable titles on dark event colours. An event bar's title now shows in white
|
||||||
|
on a dark colour and near-black on a light one, chosen automatically from the
|
||||||
|
colour's brightness, so a deep blue or purple event is legible at a glance in
|
||||||
|
the busy Week and Month views instead of dark-on-dark. This applies whether or
|
||||||
|
not colours are softened. Thanks to @ptab for the suggestion ([#21]).
|
||||||
|
- A custom snooze duration. The **Snooze duration** setting (Settings →
|
||||||
|
Notifications) gains a **Custom…** option next to the minute presets: pick any
|
||||||
|
amount and switch between minutes and hours, so a snoozed reminder comes back
|
||||||
|
after exactly the delay you want instead of only a preset one ([#40]).
|
||||||
|
- Move an event to another calendar. When editing an existing event, the
|
||||||
|
calendar row is now tappable — pick a different calendar and saving moves the
|
||||||
|
event across, instead of having to delete it and recreate it elsewhere.
|
||||||
|
Recurring series move as a whole, keeping their individually-edited and
|
||||||
|
cancelled occurrences, and any reminders and guests come along too. A calendar
|
||||||
|
can't simply be reassigned underneath an event, so Calendula recreates it on
|
||||||
|
the target and removes the original — the same approach other calendar apps
|
||||||
|
take. Thanks to @prismplex for the suggestion ([#39]).
|
||||||
|
- Open an event straight into the edit form from another app. Calendula already
|
||||||
|
answered the "new event" and "open this event" hand-offs from other apps and
|
||||||
|
widgets; it now also answers the "edit this event" one, so an assistant, task
|
||||||
|
app, or widget can send an existing event to Calendula and land on its edit
|
||||||
|
screen rather than the read-only details. A hand-off with no event attached
|
||||||
|
opens the same prefilled create form as "new event". Calendula also recognises
|
||||||
|
a couple more file labels the same calendar data arrives under (`.vcs`
|
||||||
|
vCalendar files and the `application/ics` type), so opening or sharing those
|
||||||
|
into Calendula works too.
|
||||||
|
- Keep today at the top of the agenda. A new **Always show today** setting
|
||||||
|
(Settings → Agenda, on by default) anchors today as the first entry in both the
|
||||||
|
Agenda screen and its home-screen widget even once nothing is left today —
|
||||||
|
under today's header a "No more events today" note appears — so the first
|
||||||
|
events you see are clearly today's rather than a future day's. Turn it off to
|
||||||
|
keep the agenda purely upcoming. Thanks to @ptab for the suggestion ([#35]).
|
||||||
|
- Duplicate an event. The event details now carry a **Duplicate** action that
|
||||||
|
opens the editor pre-filled with a copy of the event as a new, unsaved one, so
|
||||||
|
a one-off like a shift or an appointment can be recreated by just changing the
|
||||||
|
day and time instead of re-typing every field. The copy keeps the original's
|
||||||
|
time, and its title, location, notes, colour, guests and reminders come along;
|
||||||
|
it's saved as its own single event (any repeat is left off — add one in the
|
||||||
|
editor if you want it). Duplicate works from read-only calendars too, dropping
|
||||||
|
the copy into a writable one. Thanks to @internet-rando for the suggestion
|
||||||
|
([#52]).
|
||||||
|
- French and Polish, in early form. Calendula has started speaking French and
|
||||||
|
Polish, both contributed as community translations through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
They are partway there, so untranslated parts still show in English until they
|
||||||
|
fill out — you can already pick either under Settings → Language or in Android's
|
||||||
|
per-app language settings. Thanks to Thomas Tref (French) and Bazyli Cyran
|
||||||
|
(Polish) for getting them started; help finishing them is very welcome.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Reminders for events on another day no longer read as if they were today. A
|
||||||
|
reminder fired ahead of time — say, the day before — used to show only the
|
||||||
|
event's time, making it look like it was happening now. The notification now
|
||||||
|
says which day: **Tomorrow** or **Yesterday**, the weekday for another day this
|
||||||
|
week, or the date for anything further out. Thanks to @moonj for the report
|
||||||
|
([#46]).
|
||||||
|
- Agenda dates now read in your locale's format. The agenda's range bar and its
|
||||||
|
day headers used a fixed day-month-year layout — and the range span even mixed
|
||||||
|
two orders (e.g. "15 Jul – Aug 13, 2026") — instead of following your language's
|
||||||
|
conventions. Dates across the agenda and its widget now use your locale's own
|
||||||
|
field order, matching the rest of the app. The range bar also no longer repeats
|
||||||
|
the range's name from the selector button beside it, showing just the dates.
|
||||||
|
- Calendar gutters line up with the menu button. The Month view's week-number
|
||||||
|
column, and the Week and Day views' hour labels, sat a few pixels left of the
|
||||||
|
hamburger menu above them; they now line up with it. In Month view the day
|
||||||
|
cells also sit squarely under their weekday letters.
|
||||||
|
|
||||||
37
fastlane/metadata/android/en-US/changelogs/21600.txt
Normal file
37
fastlane/metadata/android/en-US/changelogs/21600.txt
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
### Added
|
||||||
|
- Give an event its own time zone. A new Time zone field (under "more fields" in
|
||||||
|
the event form) pins an event to a specific zone, so a call set for 8:00 AM in
|
||||||
|
New York stays 8:00 AM in New York wherever you open it, and keeps tracking
|
||||||
|
that zone across daylight-saving changes. The form shows the local equivalent
|
||||||
|
under the times, and the event's details keep your local time first with the
|
||||||
|
original noted beneath. Pick a zone from a searchable full-screen picker — by
|
||||||
|
city, IANA id, or abbreviation like "CEST". All-day events stay date-anchored
|
||||||
|
and carry no zone ([#31]).
|
||||||
|
- Put the "jump to today" button in the toolbar. A new Today button in toolbar
|
||||||
|
setting (Settings → Appearance, off by default) swaps the floating corner
|
||||||
|
button for a permanent today icon in the top bar — always there, matching the
|
||||||
|
familiar calendar-app pattern. Leave it off to keep the floating button ([#60]).
|
||||||
|
- Show the app as "Calendar" in your launcher. A new App name setting
|
||||||
|
(Settings → Appearance) switches the launcher label from "Calendula" to the
|
||||||
|
generic "Calendar" for anyone who prefers it — handy on launchers that can't
|
||||||
|
rename apps themselves. Only the launcher name changes; your home-screen icon
|
||||||
|
may move to a new spot after switching ([#44]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Dates in the Month, Week and Day title bars now follow your language and
|
||||||
|
region instead of one hardcoded layout. Every date was rendered in a fixed
|
||||||
|
German-style order with a trailing dot on the day number, whatever your
|
||||||
|
settings: US English showed "Fri, 17. Jul 2026" where it should read
|
||||||
|
"Fri, Jul 17". The Agenda view already formatted correctly, so the two
|
||||||
|
disagreed about the same date. All four views now share one formatter, and the
|
||||||
|
day/month order, the separators and the ordinal all come from your locale —
|
||||||
|
so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17",
|
||||||
|
each correct for where you are ([#60]).
|
||||||
|
- The title bar drops the year while you're in the current one — "July" rather
|
||||||
|
than "July 2026". The year reappears the moment you page out of the current
|
||||||
|
year, which is when it tells you something you didn't already know.
|
||||||
|
- The Week view's title now names the month instead of spelling out the day range.
|
||||||
|
"24. Jun – 31. Jun" restated the day numbers already printed in the column
|
||||||
|
headers right below it, in the widest string in the bar. A week that straddles
|
||||||
|
two months keeps the outgoing month until it is fully gone ([#60]).
|
||||||
|
|
||||||
Submodule floret-kit updated: 78c5fd1fd4...75b90d1b3b
Reference in New Issue
Block a user