Compare commits

..

4 Commits

Author SHA1 Message Date
11adfd66bc Give search results the same width as other lists (#80)
The results list added its own 16dp horizontal inset on top of the one
GroupedRow already applies, so search rows sat 32dp in from each edge while
agenda and settings rows sit at 16dp.
2026-08-08 15:54:06 +02:00
bfe493e5c9 Fade the search bar into selection mode (#80)
The bar cut straight from the search field to the contextual bar. Both now
live in one top app bar whose container colour eases and whose title,
navigation and action slots fade through, so entering and leaving selection
mode is a transition rather than a swap.
2026-08-08 15:52:45 +02:00
7ab8fcb5f5 Select search results and delete them together (#80)
Long-press a result to enter selection mode; the search field swaps for a
contextual bar with the count, select-all and delete. Search hits now carry
whether their event recurs, so a batch holding one asks the usual scope
question once and applies it to the recurring hits only. Results in
read-only calendars stay out of the selection rather than failing at write
time, and the list re-runs its query after the batch.
2026-08-02 20:56:16 +02:00
Jean-Luc Makiola
4e38866dfd Drag an event to reschedule it (#135)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/135
2026-08-02 20:36:09 +02:00
205 changed files with 1533 additions and 14523 deletions

1
.gitattributes vendored
View File

@@ -5,4 +5,3 @@
*.jpg binary *.jpg binary
*.gif binary *.gif binary
*.webp binary *.webp binary
app/src/test/resources/ics/*.ics -text

View File

@@ -551,11 +551,12 @@ jobs:
env: env:
VERSION: ${{ needs.detect.outputs.version }} VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }} VERSION_CODE: ${{ needs.detect.outputs.version_code }}
# Where the bundle lands. `production` — the release itself is already the # Where the bundle lands. `internal` by default so a release reaches
# gate (a bumped versionName only reaches main after on-device review), so # testers rather than the public, and promotion to production stays a
# a second manual promotion in the Play Console bought nothing but delay. # deliberate human action in the Play Console — the same posture as
# Override with the PLAY_TRACK repo variable to stage a release instead. # holding UI releases for on-device review. Override with the PLAY_TRACK
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }} # repo variable once the flow is trusted.
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'internal' }}
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }} PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard # Set PLAY_DRY_RUN=true to validate the edit against the API and discard
# it instead of committing — used to rehearse the first upload. # it instead of committing — used to rehearse the first upload.

View File

@@ -7,249 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed
- Month-view event titles no longer end in an ellipsis. The "…" took the width
of a couple of characters and told you nothing you couldn't already see, so
the title now simply runs to the edge of its chip — a few more letters per
cell, which is often the difference between two events you can tell apart and
two you can't ([#164]).
- The calendar titles now shorten instead of being cut off. When the full month
name doesn't fit the top bar, the month and week views fall back to its
three-letter form and the day view drops the weekday, rather than trailing off
mid-word at large font sizes. The view switcher, the title and the agenda's
range bar also line up with the grid underneath them ([#165]).
## [2.19.4] — 2026-09-01
### Fixed
- **A deleted occurrence no longer comes back when the series is re-timed.**
Delete a single occurrence of a repeating event, then change the time of the
whole series, and the occurrence you had removed reappeared. Doing it the
other way round — "This and all following events" — lost every removal in the
part of the series being changed. Removals now travel with the event: they
keep their place in the series across a time change, a move to a different
day, a timezone change and a switch to or from an all-day event, and they
carry over when a series is split. Repeating events on your device's own
calendars are affected, including the birthday and anniversary calendars
Calendula creates from your contacts ([#248]).
- **Home-screen widgets now turn the page at midnight.** Both the month and the
agenda widget kept highlighting yesterday as "today" — and the agenda kept
greying out the wrong events as already past — until you paged the month back
and forth or removed and re-added the widget. Calendula now wakes itself at the
day boundary and redraws, and re-arms after a reboot, a clock change or a
flight into another timezone. Paging the month widget forward and back also
stops quietly pinning it to that month, so it follows the date again instead of
being stranded on the month you happened to be looking at ([#228]).
- **"Only this event" now actually saves your edit.** On some calendars —
including Google ones that still show as on-device, and any local calendar —
editing a single occurrence of a repeating event did nothing at all: the scope
dialog closed, the edit screen stayed put, and saving again just repeated it.
Android can only attach a single-occurrence change to its series once the
calendar has been synced at least once, so on those calendars the change had
nowhere to go. Calendula now removes that one occurrence from the series and
saves the edit as its own event instead, which is what you see either way. A
save that does fail also says so for longer, rather than flashing past
([#234]).
- **A failed import no longer costs you the whole file.** One event the calendar
refuses used to abort the entire import with nothing on screen but "couldn't
read this file". Events are now added one at a time: anything rejected is
counted and shown, and the rest still arrive. A faulty repeat rule in the file
is repaired instead of being handed on ([#225]).
- **All-day reminders survive a backup and an import.** They fire at the hour you
chose, not at midnight UTC — the same rule the app already applied to all-day
events you create yourself — and an export no longer leaves them out of the
file entirely. An imported yearly birthday fires at the right hour too, even
though the file dates it back to the year of birth ([#225]).
- **Birthdays and anniversaries imported from Fossify Calendar now show up.**
Fossify writes the ones it mirrors from your contacts as events that start and
end on the same day, which the calendar read as lasting no time at all: they
imported without complaint and then appeared nowhere. Any all-day event that
ends where it starts, or carries no end at all, is now a one-day event
([#225]).
- **A backup no longer brings back occurrences you had deleted.** Remove a single
occurrence of a repeating event, back up, restore — and it was there again,
because the removals never made it into the file. They travel with the series
now, so a restored calendar looks the way you left it ([#225]).
- **A reminder left on your account's default no longer went off as the event
started.** An event whose reminder says "Default" is the account's to time, not
Calendula's — it was reading that setting as a lead time of its own and firing a
minute after the event began. It now leaves such a reminder alone, and says so
on the event.
- **"Only this event" on an event that doesn't repeat now edits or deletes it.**
Deleting used to write an exclusion onto a one-off event instead — which left
the event there and lost its end time — and editing filed the change away as an
exception to nothing, so it vanished.
- **An import that the calendar cuts short now tells you how far it got.** Losing
the calendar or the permission part way through a big file reported the whole
import as failed, so importing again added everything twice.
- **An event that runs past midnight can be dragged by either half.** Its second
half couldn't be picked up at all, and the first could only be moved later. Both
halves now move, and both are previewed while you hold either one, so the event
reads as moving rather than growing. The blocks are cut square at midnight
instead of rounded off, a month row draws an event crossing into the next week
as one bar, and a move across a daylight-saving change no longer lands an hour
out ([#253]).
### Added
- **More of an imported `.ics` survives the trip**: tasks come across as events
rather than being dropped in silence, deleted occurrences of a repeating event
stay deleted, a calendar named like the one the file came from is preselected,
and event colours are carried over — matched to the closest colour your
calendar's account offers, so a migrated calendar still reads at a glance
([#225]).
- **Belarusian, Hungarian and Slovak**, all three contributed as community
translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
Belarusian covers about nine tenths of the app, Slovak about a third and
Hungarian is just under way, so untranslated parts still show in English until
they fill out. Pick any of them under Settings → Language or in Android's
per-app language settings. Thanks to Dmitri Gabinski (Belarusian), Ábel Nagy
(Hungarian) and Julie (Slovak); help finishing them is very welcome.
### Changed
- **Arabic, Polish and Simplified Chinese are now complete**, all three carried
the rest of the way this cycle — Simplified Chinese from a fifth of the app to
all of it. Czech and Russian grew too — Russian to about half — and still have
room. Thanks to Sup99999 (Arabic), Bazyli Cyran (Polish), JeanWenzel
(Simplified Chinese), James Trivette (Czech), and SpiritOTHawk and Artur
(Russian).
## [2.19.3] — 2026-08-22
### Added
- **Czech, Japanese and Russian**, all three contributed as community
translations through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
They are early — Czech covers about a third of the app, Russian a fifth,
Japanese has just started — so untranslated parts still show in English until
they fill out. Pick any of them under Settings → Language or in Android's
per-app language settings. Thanks to James Trivette (Czech), ame0011
(Japanese), and chip and Pablo (Russian); help finishing them is very welcome.
### Changed
- **Brazilian Portuguese is now complete**, covering the whole app. Thanks to
bkrz for carrying it the rest of the way.
## [2.19.2] — 2026-08-17
### Changed
- **A meeting you declined is now struck through** wherever it appears — month,
week, day, agenda, search and both widgets — and no longer schedules a
reminder. Declining an invitation in Google Calendar left the event looking
like any other in Calendula, and it still notified you about a meeting you had
said no to. It stays visible rather than disappearing: the organiser still
expects an answer from you, and the slot is still spoken for ([#180]).
### Fixed
- **Tapping an event in month view opens the event**, not the day it sits on.
Every month style is affected — page, rolling, seamless weeks — and until now
the only way to reach an event from the month was to open its day first and
find it again there. Tapping anywhere else in the cell still opens the day
([#187]).
- **An edit made to an event now shows the moment you re-open it.** Adding a
description to an event and opening it again showed the sheet as it was before
the save, because the detail was only re-read when a different occurrence was
opened ([#196]).
- **The month widget's arrows stopped working after a couple of taps.** The grid
was serialised as roughly 740 views, and one update ran to half a megabyte —
more than the launcher's buffer takes. The third update overran it, and Android
responded by dropping the whole widget host, which killed updates for *every*
widget on the home screen, ours and other apps', until the launcher rebound.
The grid now draws 192 views, and a resized widget reflows to its new size
instead of clipping ([#214]).
- Tapping a widget's refresh or month arrows redraws that widget by its own id
instead of asking Android to update all of them, which does nothing in a
process the tap has just woken from cold ([#18]).
- **Jump-to-today in seamless weeks lands on the current week** instead of
leaving a sliver of the previous row on screen ([#191]).
- Day and week view no longer run their events flush against the right edge
([#192]).
## [2.19.1] — 2026-08-11
### Added
- **Drag to reschedule can be turned off.** Settings → Views → *Drag to
reschedule*, on by default. Off, no event can be picked up in the month, week
or day view — for anyone who kept moving appointments by accident while
scrolling ([#173]).
- An early Brazilian Portuguese translation. Calendula has started speaking
Brazilian Portuguese, contributed as a community translation through
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
About a third of the app is covered, so the rest still shows in English — you
can pick it under Settings → Language or in Android's per-app language
settings. Thanks to
[bkrz](https://weblate.dev.jeanlucmakiola.de/user/bkrz/) for getting it
started; help finishing it is very welcome.
### Fixed
- **The language picker forgot which language was picked.** Android hands the
applied language back with its script filled in — Simplified Chinese goes in
as `zh-CN` and comes out as `zh-Hans-CN` — so after a restart no entry in
Settings → Language showed as selected, and the row above it named the
language the long way round. Only languages that imply a script were
affected.
## [2.19.0] — 2026-08-10
### Added
- **Drag an event to reschedule it.** Press and hold an event in the month,
week or day view and drop it on another time or day — the only way to move an
appointment used to be opening it and editing its start and end by hand. The
block follows your finger, the timeline scrolls when you carry it to an edge,
and the event's length is preserved: a 90-minute meeting dropped on 09:00 ends
at 10:30. In week and day view the drop snaps to 15-minute steps; in month
view it moves the event to the day you dropped it on and leaves the time
alone. A confirmation names the new slot and offers **Undo** wherever the move
can be taken back in one step. Events you can't edit — read-only calendars, and
birthdays and anniversaries mirrored from your contacts — can't be picked up.
Rescheduling isn't pointer-only: a screen reader gets a **Move…** action on
every event that can be moved ([#68]).
- Dragging a **recurring** event asks what it should apply to, the same *this
event / this and following / all events* choice a save does. Where moving the
whole series would change which weekday it falls on, the rule is rewritten to
match — a weekly Monday event dropped on a Wednesday becomes a weekly
Wednesday event. Where it can't be rewritten safely, the prompt says so and
offers to move just the one occurrence, rather than quietly leaving the
series behind ([#68]).
- **Delete several search results at once.** Long-press a hit in search to enter
selection mode, tap the rest (or **Select all**), and delete the lot in one
action — finding and removing, say, every leftover event with the same title
no longer means opening them one at a time. Recurring hits in the batch ask
once for the whole selection instead of once each, results on read-only
calendars can't be selected, and the deletion is confirmed before it runs
([#80]).
### Changed
- **First launch is now a short wizard** instead of a single permission screen.
It counts its steps, each one can be stepped back out of, and beyond granting
calendar access it offers two things worth deciding once: turning on automatic
backup — chosen inline, folder picker and all, since events that live only on
the phone are lost with it — and choosing which view Calendula opens on, with
a live preview of the month, week, day and agenda layouts rather than four
names in a list. Both are skippable, everything chosen is changeable in
Settings afterwards, and the backup step is offered only where nothing
writable is being synced anywhere. Existing installs are not re-onboarded —
revoking and re-granting the calendar permission later does not restart the
wizard ([#163]).
- The one-time notice about calendars switched off for this device is now a step
in the flow rather than a dialog thrown over the app on first open.
### Fixed
- Removed the deprecated `statusBarColor` / `navigationBarColor` theme
attributes the Play Console flags on Android 15 and up, where they no longer
do anything. The launch backdrop now picks its status-bar icons from the
light/dark resource instead, so they stay legible over it either way
([#123]).
## [2.18.1] — 2026-08-06
### Fixed
- Tapping an empty slot in week or day view creates the event at the hour you
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
— or changing **Hour height** in Settings — left the tap still being measured
against the old spacing, so the new event landed at some other hour entirely
([#148]).
## [2.18.0] — 2026-07-31 ## [2.18.0] — 2026-07-31
### Added ### Added
@@ -1566,22 +1323,3 @@ automatically, with zero telemetry and no internet permission.
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57 [#57]: https://codeberg.org/jlmakiola/calendula/issues/57
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56 [#56]: https://codeberg.org/jlmakiola/calendula/issues/56
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114 [#114]: https://codeberg.org/jlmakiola/calendula/issues/114
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148
[#68]: https://codeberg.org/jlmakiola/calendula/issues/68
[#80]: https://codeberg.org/jlmakiola/calendula/issues/80
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
[#192]: https://codeberg.org/jlmakiola/calendula/issues/192
[#196]: https://codeberg.org/jlmakiola/calendula/issues/196
[#214]: https://codeberg.org/jlmakiola/calendula/issues/214
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253

View File

@@ -18,8 +18,6 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
<p> <p>
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a> <a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
&nbsp; &nbsp;
<a href="https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula"><img src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="56"></a>
&nbsp;
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a> <a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
&nbsp; &nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a> <a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
@@ -88,7 +86,7 @@ Pick whichever channel you already use — they all install the same app:
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup | | [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once | | [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium | | [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
| [Google Play](#google-play) | Per release | Signed with Google's key — see below | | [Google Play](#google-play-coming-soon) | — | Coming soon |
| [Build from source](#build-from-source) | Whenever you build | Full control | | [Build from source](#build-from-source) | Whenever you build | Full control |
### F-Droid (recommended) ### F-Droid (recommended)
@@ -133,14 +131,18 @@ or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new → *Add*. Either way, Obtainium tracks the releases and prompts you when a new
one appears. one appears.
### Google Play ### Google Play (coming soon)
Calendula is live on Google Play: Calendula is on its way to Google Play as an additional channel. It isn't live
**[play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula)**. yet — this section gets a link once it is. Play builds will be signed with
Google's key rather than mine, so switching between Play and any other channel
will require an uninstall.
Play builds are signed with Google's key rather than mine, so switching between > **Testers wanted.** Play requires a round of closed testing before the app can
Play and any other channel requires an uninstall (and with it, a fresh start for > go public, and I'm still looking for testers. If you'd like to help, email
app settings — your events live in the system calendar and are unaffected). > **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the
> Google account address you want to use — that address is what I need to add you
> to the closed test.
### Build from source ### Build from source
@@ -149,7 +151,7 @@ The build is a plain Gradle build with no proprietary dependencies — see
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share <sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
the same signing key, so you can switch freely between them without the same signing key, so you can switch freely between them without
reinstalling. Google Play is the exception — see above.</sub> reinstalling.</sub>
## 📚 Documentation ## 📚 Documentation

View File

@@ -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 = 21904 versionCode = 21800
versionName = "2.19.4" versionName = "2.18.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -50,9 +50,3 @@
# the real names also survives app updates, which would otherwise renumber the # the real names also survives app updates, which would otherwise renumber the
# obfuscated name and orphan the stored mapping. # obfuscated name and orphan the stored mapping.
-keep class * extends androidx.glance.appwidget.GlanceAppWidget -keep class * extends androidx.glance.appwidget.GlanceAppWidget
# Belt and braces one level up: the two receivers are nearly as alike, and the
# provider map is keyed off the receiver component too. Redundant today (AGP's
# manifest-derived rules cover them), but #89 cost a release to diagnose and the
# guarantee should not rest on a component staying in the manifest.
-keep class * extends androidx.glance.appwidget.GlanceAppWidgetReceiver

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.permission
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule

View File

@@ -330,13 +330,9 @@
</receiver> </receiver>
<!-- Keeps both widgets fresh: the calendar provider broadcasts <!-- Keeps both widgets fresh: the calendar provider broadcasts
PROVIDER_CHANGED on any data change (our writes and external sync). PROVIDER_CHANGED on any data change (our writes and external sync),
The day boundary arrives as the app's own ROLLOVER alarm (#228), by and the system broadcasts the date/time ones at midnight / clock
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is changes so "today" highlighting rolls over. -->
a free extra only, since Android 8+ withholds it from manifest
receivers. The four below re-arm that alarm: TIME_SET /
TIMEZONE_CHANGED move the boundary, boot / package-replace wipe it.
Exported: the system broadcasts arrive from outside the app. -->
<receiver <receiver
android:name=".widget.WidgetUpdateReceiver" android:name=".widget.WidgetUpdateReceiver"
android:exported="true"> android:exported="true">
@@ -350,8 +346,6 @@
<action android:name="android.intent.action.DATE_CHANGED" /> <action android:name="android.intent.action.DATE_CHANGED" />
<action android:name="android.intent.action.TIME_SET" /> <action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" /> <action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter> </intent-filter>
</receiver> </receiver>

View File

@@ -10,7 +10,6 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
import de.jeanlucmakiola.floret.crash.CrashConfig import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -45,19 +44,6 @@ class CalendulaApp : Application() {
reconcileSpecialDates() reconcileSpecialDates()
reconcileCalendarVisibility() reconcileCalendarVisibility()
startReminderDelivery() startReminderDelivery()
reconcileWidgetRollover()
}
/**
* Re-arm the widgets' midnight rollover from whatever is actually placed
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
* dropped by a force-stop is armed again the next time the app is opened.
* Off the main thread: a handful of binder calls on every process start.
*/
private fun reconcileWidgetRollover() {
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
WidgetRolloverScheduler.sync(this@CalendulaApp)
}
} }
/** /**

View File

@@ -31,13 +31,9 @@ private const val MILLIS_PER_MINUTE = 60_000L
* event (a whole-day multiple; sub-day remainders are dropped), so it fires at * event (a whole-day multiple; sub-day remainders are dropped), so it fires at
* [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be * [timeOfDayMinutes] (minutes from local midnight) in [zone]. The result may be
* **negative** — e.g. "at time of event" at 09:00 CEST encodes to 420, meaning * **negative** — e.g. "at time of event" at 09:00 CEST encodes to 420, meaning
* the provider fires *after* DTSTART; this is valid and must not be clamped. The * the provider fires *after* DTSTART; this is valid and must not be clamped.
* one negative it never is is [MINUTES_DEFAULT]: the sentinel and the encoding * A negative [semanticMinutes] is the "provider default" sentinel and passes
* share a value space, so an encoding that lands on it is nudged one minute past * through unchanged.
* it rather than being read back as "use the account default". That sentinel is
* also the one [semanticMinutes] that passes through unencoded — a lead time may
* itself be negative (a foreign row firing the day *after* the event), and
* waving those through would change when they fire.
*/ */
internal fun toProviderAllDayMinutes( internal fun toProviderAllDayMinutes(
semanticMinutes: Int, semanticMinutes: Int,
@@ -45,13 +41,12 @@ internal fun toProviderAllDayMinutes(
zone: ZoneId, zone: ZoneId,
timeOfDayMinutes: Int, timeOfDayMinutes: Int,
): Int { ): Int {
if (semanticMinutes == MINUTES_DEFAULT) return semanticMinutes if (semanticMinutes < 0) return semanticMinutes
val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() val utcMidnight = startDate.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong()) val fire = startDate.minusDays((semanticMinutes / MINUTES_PER_DAY).toLong())
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60)) .atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
.atZone(zone).toInstant().toEpochMilli() .atZone(zone).toInstant().toEpochMilli()
val raw = ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt() return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
return if (raw == MINUTES_DEFAULT) MINUTES_DEFAULT - 1 else raw
} }
/** /**
@@ -75,52 +70,15 @@ internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): Local
return LocalDate.of(today.year, month, day) return LocalDate.of(today.year, month, day)
} }
/**
* The date an **imported** all-day event's reminder offset is sampled at.
*
* A one-off fires once, on its own date. A series' `DTSTART` is only an anchor
* and may be ancient — Fossify writes a year-less contact birthday at 1970, a
* year whose rules predate DST across most of Europe, and a real birth year is
* usually older still — so sampling there skews every modern occurrence by the
* offset delta (the same trap [nextYearlyOccurrence] exists for). A yearly
* series is therefore sampled at its next occurrence of the anchor's month/day,
* which shares the season; any other series at [today], which its upcoming
* occurrences are near.
*
* The decode side ([fromProviderAllDayMinutes]) only asks which local *day* the
* encoded instant falls on, so it still reads the lead time back correctly from
* the anchor's own date.
*/
internal fun importedAllDayReminderDate(
startDate: LocalDate,
recurrenceRule: String?,
today: LocalDate,
): LocalDate = when {
recurrenceRule.isNullOrBlank() -> startDate
recurrenceRule.contains("FREQ=YEARLY", ignoreCase = true) ->
nextYearlyOccurrence(startDate.monthValue, startDate.dayOfMonth, today)
startDate.isBefore(today) -> today
else -> startDate
}
/** /**
* Recover the semantic whole-day lead time from a raw all-day reminder * Recover the semantic whole-day lead time from a raw all-day reminder
* [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the * [rawMinutes] — the inverse of [toProviderAllDayMinutes], for the form and the
* detail screen. Delegates to [allDayLeadDays], so the day displayed is the day * detail screen. Delegates to [allDayLeadDays], so the day displayed is the day
* the reminder actually fires on. * the reminder actually fires on.
*
* [MINUTES_DEFAULT] passes through unchanged, the mirror of the encode side. A
* real encoding is often negative, so only that exact value may be treated as the
* sentinel — decoding it would land it on the start date and turn "use the
* account default" into a concrete at-start alarm.
*/ */
internal fun fromProviderAllDayMinutes( internal fun fromProviderAllDayMinutes(
rawMinutes: Int, rawMinutes: Int,
startDate: LocalDate, startDate: LocalDate,
zone: ZoneId, zone: ZoneId,
timeOfDayMinutes: Int, timeOfDayMinutes: Int,
): Int = if (rawMinutes == MINUTES_DEFAULT) { ): Int = allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
rawMinutes
} else {
allDayLeadDays(rawMinutes, startDate, zone, timeOfDayMinutes).toInt() * MINUTES_PER_DAY
}

View File

@@ -27,15 +27,12 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.curatedForPicker import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch
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.SearchCandidate
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import java.time.Instant import java.time.Instant
@@ -65,14 +62,12 @@ interface CalendarDataSource {
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail? fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
/** /**
* Master/one-off events that may match [query], across all calendars: every * Master/one-off events whose title, description or location contains
* whitespace-separated token has to appear in the title, description or * [query] (case-insensitive), across all calendars, newest first. Reads the
* location. Reads the Events table directly so the search is unbounded in * Events table directly so the search is unbounded in time; exception rows
* time; exception rows are excluded (see [SearchProjection]). A deliberate * are excluded (see [SearchProjection]). [query] is assumed non-blank.
* superset — [EventSearch] makes the final call and orders the hits.
* [query] is assumed non-blank.
*/ */
fun searchEvents(query: String): List<SearchCandidate> fun searchEvents(query: String): List<EventInstance>
/** /**
* The event-colour palette the calendar's account publishes * The event-colour palette the calendar's account publishes
@@ -85,25 +80,14 @@ interface CalendarDataSource {
*/ */
fun eventColorPalette(calendarId: Long): List<EventColorOption> fun eventColorPalette(calendarId: Long): List<EventColorOption>
/**
* The same palette **uncurated** — every key the account publishes, in
* provider order. Curation is a display concession (it folds look-alikes and
* drops the neutrals outright from an oversized palette), so matching a
* colour that came from outside the account has to run against the full set:
* those keys are all the calendar accepts. See [eventColorPalette].
*/
fun publishedEventColors(calendarId: Long): List<EventColorOption>
/** /**
* Every master/one-off event of the writable local calendars, mapped for a * Every master/one-off event of the writable local calendars, mapped for a
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception * whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
* rows are excluded (see [EventExportProjection]). When [calendarIds] is * rows are excluded (see [EventExportProjection]). When [calendarIds] is
* given, only those calendars are exported (still intersected with the * given, only those calendars are exported (still intersected with the
* eligible set); `null` exports every eligible calendar. * eligible set); `null` exports every eligible calendar.
* [allDayReminderTimeMinutes]: needed to write all-day reminders as whole-day
* lead times rather than raw provider offsets (see [toIcsEvent]).
*/ */
fun exportableEvents(calendarIds: Set<Long>?, allDayReminderTimeMinutes: Int): List<IcsEvent> fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
/** /**
* The non-empty `Events.UID_2445` values present in [calendarId] — used to * The non-empty `Events.UID_2445` values present in [calendarId] — used to
@@ -113,19 +97,10 @@ interface CalendarDataSource {
/** /**
* Insert a parsed `.ics` event into [calendarId], preserving its UID (or * Insert a parsed `.ics` event into [calendarId], preserving its UID (or
* minting one when absent); returns the new `Events._ID`. * minting one when absent); returns the new `Events._ID`. Reminders are
* * written as the file's raw lead minutes (METHOD_ALERT).
* [colorPalette] is the target account's published event colours
* ([publishedEventColors]), looked up once per import;
* [allDayReminderTimeMinutes] is the user's preferred all-day firing time,
* applied exactly as a hand-created event's is.
*/ */
fun insertImportedEvent( fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long
event: ParsedIcsEvent,
calendarId: Long,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long
/** /**
* Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns; * Create a new device-only (`ACCOUNT_TYPE_LOCAL`) calendar the app owns;
@@ -253,21 +228,14 @@ interface CalendarDataSource {
): Long ): Long
/** /**
* Change a single occurrence of a recurring event at [beginMillis] (the * Change a single occurrence of a recurring event by inserting a
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the * modified-occurrence exception at [beginMillis] (the occurrence's
* `Events._ID` of the row now holding them. * `Instances.BEGIN`) carrying [form]'s values; returns the exception
* * row's `Events._ID`. [allDayReminderTimeMinutes]: see [insertEvent].
* A series with a `_sync_id` gets a modified-occurrence exception. One
* without gets the occurrence excluded from the parent via EXDATE plus a
* standalone event carrying the edits — an exception cannot link to its
* parent there (Codeberg #234, the same constraint as [deleteOccurrence]).
* A row that turns out not to recur at all is edited whole, which is what
* [original] is for. [allDayReminderTimeMinutes]: see [insertEvent].
*/ */
fun updateOccurrence( fun updateOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,
original: EventForm,
form: EventForm, form: EventForm,
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
): Long ): Long
@@ -617,23 +585,20 @@ class AndroidCalendarDataSource @Inject constructor(
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
} }
override fun searchEvents(query: String): List<SearchCandidate> { override fun searchEvents(query: String): List<EventInstance> {
ensureObserversRegistered() ensureObserversRegistered()
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS) val trimmed = query.trim()
if (tokens.isEmpty()) return emptyList() if (trimmed.isEmpty()) return emptyList()
// Only a pre-filter: EventSearch re-checks every token, including the // Escape the SQL LIKE wildcards so a literal % or _ in the query matches
// ones the cap above dropped. // itself instead of acting as a wildcard.
val patterns = tokens.map { likePatterns(it) } val escaped = trimmed
val match = patterns.joinToString(" AND ") { variants -> .replace("\\", "\\\\")
variants.joinToString(" OR ", prefix = "(", postfix = ")") { .replace("%", "\\%")
"${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " + .replace("_", "\\_")
val like = "%$escaped%"
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " + "${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'" "${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
}
}
val args = patterns
.flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } }
.toTypedArray()
val selection = "($match) AND " + val selection = "($match) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " + "${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL" "${CalendarContract.Events.ORIGINAL_ID} IS NULL"
@@ -641,17 +606,17 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.CONTENT_URI, CalendarContract.Events.CONTENT_URI,
SearchProjection.COLUMNS, SearchProjection.COLUMNS,
selection, selection,
args, arrayOf(like, like, like),
CalendarContract.Events.DTSTART + " DESC", CalendarContract.Events.DTSTART + " DESC",
)?.use { c -> )?.use { c ->
val reader = CursorColumnReader(c) val reader = CursorColumnReader(c)
val out = ArrayList<SearchCandidate>(c.count) val out = ArrayList<EventInstance>(c.count)
while (c.moveToNext()) { while (c.moveToNext()) {
val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
val base = reader.toSearchResult() ?: continue val base = reader.toSearchResult() ?: continue
// A recurring master's DTSTART is the series start; date the row // A recurring master's DTSTART is the series start; show its
// by its nearest occurrence instead. // nearest occurrence instead so the date is the one the user
val event = if (base.isRecurring) { // actually cares about (and sorting reflects it).
out += if (base.isRecurring) {
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) -> nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
base.copy( base.copy(
start = begin.toKotlinInstantFromEpochMillis(), start = begin.toKotlinInstantFromEpochMillis(),
@@ -661,44 +626,11 @@ class AndroidCalendarDataSource @Inject constructor(
} else { } else {
base base
} }
// The raw title, so matching can't catch on the placeholder an
// untitled event is drawn with.
out += SearchCandidate(
event = event,
description = description,
title = reader.getString(SearchProjection.IDX_TITLE),
)
} }
out out
} ?: emptyList() } ?: emptyList()
} }
/**
* One token as the `LIKE` patterns to OR together. SQLite folds case for
* ASCII only, so a token with cased non-ASCII letters is also queried lower-,
* upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding
* those letters away instead would make "москва" `%______%` — a full scan.
*/
private fun likePatterns(token: String): List<String> {
if (token.none { it.code > 127 && (it.isUpperCase() || it.isLowerCase()) }) {
return listOf(likePattern(token))
}
val lower = token.lowercase()
return listOf(token, lower, token.uppercase(), lower.replaceFirstChar(Char::uppercaseChar))
.distinct()
.map(::likePattern)
}
private fun likePattern(token: String): String {
val sb = StringBuilder("%")
for (c in token) {
// A literal wildcard from the query matches itself.
if (c == '%' || c == '_' || c == '\\') sb.append('\\')
sb.append(c)
}
return sb.append('%').toString()
}
/** /**
* The occurrence of [eventId] nearest to now: the soonest upcoming one * The occurrence of [eventId] nearest to now: the soonest upcoming one
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one * within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
@@ -749,10 +681,7 @@ class AndroidCalendarDataSource @Inject constructor(
} }
} }
override fun eventColorPalette(calendarId: Long): List<EventColorOption> = override fun eventColorPalette(calendarId: Long): List<EventColorOption> {
publishedEventColors(calendarId).curatedForPicker()
override fun publishedEventColors(calendarId: Long): List<EventColorOption> {
val account = calendarAccount(calendarId) ?: return emptyList() val account = calendarAccount(calendarId) ?: return emptyList()
return resolver.query( return resolver.query(
CalendarContract.Colors.CONTENT_URI, CalendarContract.Colors.CONTENT_URI,
@@ -770,13 +699,11 @@ class AndroidCalendarDataSource @Inject constructor(
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) } c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
} }
?.filter { it.key.isNotEmpty() } ?.filter { it.key.isNotEmpty() }
?.curatedForPicker()
?: emptyList() ?: emptyList()
} }
override fun exportableEvents( override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
// Only the local calendars the app owns and can write — synced calendars // Only the local calendars the app owns and can write — synced calendars
// already have a backup (their server). Exclude the managed special-dates // already have a backup (their server). Exclude the managed special-dates
// mirror calendars: their events are derived from contacts, not authored // mirror calendars: their events are derived from contacts, not authored
@@ -811,7 +738,6 @@ class AndroidCalendarDataSource @Inject constructor(
reader.toIcsEvent( reader.toIcsEvent(
reminderMinutes = queryReminders(eventId).map { it.minutes }, reminderMinutes = queryReminders(eventId).map { it.minutes },
calendarName = names[calendarId], calendarName = names[calendarId],
allDayReminderTimeMinutes = allDayReminderTimeMinutes,
) )
} }
} ?: emptyList() } ?: emptyList()
@@ -820,83 +746,76 @@ class AndroidCalendarDataSource @Inject constructor(
override fun existingUids(calendarId: Long): Set<String> = resolver.query( override fun existingUids(calendarId: Long): Set<String> = resolver.query(
CalendarContract.Events.CONTENT_URI, CalendarContract.Events.CONTENT_URI,
arrayOf(CalendarContract.Events.UID_2445), arrayOf(CalendarContract.Events.UID_2445),
// DELETED rows linger until a sync adapter purges them; counting those
// as present would make a re-import skip everything the user has since
// deleted, reporting "all duplicates" and importing nothing.
"${CalendarContract.Events.CALENDAR_ID} = ? AND " + "${CalendarContract.Events.CALENDAR_ID} = ? AND " +
"${CalendarContract.Events.UID_2445} IS NOT NULL AND " + "${CalendarContract.Events.UID_2445} IS NOT NULL",
"${CalendarContract.Events.DELETED} = 0",
arrayOf(calendarId.toString()), arrayOf(calendarId.toString()),
null, null,
)?.use { c -> )?.use { c ->
buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) } buildSet { while (c.moveToNext()) c.getString(0)?.takeIf { it.isNotEmpty() }?.let(::add) }
} ?: emptySet() } ?: emptySet()
override fun insertImportedEvent( override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
event: ParsedIcsEvent, val startMillis = event.start.toEpochMillis()
calendarId: Long, val endMillis = event.end.toEpochMillis()
allDayReminderTimeMinutes: Int, val values = ContentValues().apply {
colorPalette: List<EventColorOption>, put(CalendarContract.Events.CALENDAR_ID, calendarId)
): Long {
val values = buildImportedEventValues(
event = event,
calendarId = calendarId,
// Preserve the file's UID so a re-import dedups against it; mint one // Preserve the file's UID so a re-import dedups against it; mint one
// only when the source event carried none. // only when the source event carried none.
uid = event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula", put(
palette = colorPalette, CalendarContract.Events.UID_2445,
event.uid?.takeIf { it.isNotBlank() } ?: "${UUID.randomUUID()}@calendula",
) )
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues()) put(CalendarContract.Events.TITLE, event.summary.trim())
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, startMillis)
if (event.recurrenceRule == null) {
put(CalendarContract.Events.DTEND, endMillis)
} else {
put(CalendarContract.Events.RRULE, event.recurrenceRule)
put(
CalendarContract.Events.DURATION,
importDuration(startMillis, endMillis, event.isAllDay),
)
}
// All-day rows live at UTC midnights (the file already encodes them so);
// timed rows keep the event's own zone.
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
event.location?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
event.description?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw WriteFailedException("import event into calendar id=$calendarId") ?: throw WriteFailedException("import event into calendar id=$calendarId")
val eventId = ContentUris.parseId(uri) val eventId = ContentUris.parseId(uri)
val zone = ZoneId.systemDefault() // Raw lead minutes straight from the file's VALARMs (best-effort, like insertEvent).
// An all-day reminder is stored the same way a hand-created one is, so it event.reminderMinutes.distinct().filter { it >= 0 }.forEach { minutes ->
// fires at the time the user picked rather than at UTC midnight — sampled
// where it will actually fire, not at an ancient recurrence anchor
// (see [importedAllDayReminderDate]).
val reminderDate = if (event.isAllDay) {
importedAllDayReminderDate(
startDate = Instant.ofEpochMilli(event.start.toEpochMilliseconds())
.atZone(ZoneOffset.UTC).toLocalDate(),
recurrenceRule = event.recurrenceRule,
today = LocalDate.now(zone),
)
} else {
null
}
event.semanticReminderMinutes().forEach { minutes ->
val providerMinutes = if (reminderDate != null) {
toProviderAllDayMinutes(
semanticMinutes = minutes,
startDate = reminderDate,
zone = zone,
timeOfDayMinutes = allDayReminderTimeMinutes,
)
} else {
minutes
}
val reminder = ContentValues().apply { val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId) put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, providerMinutes) put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
} }
// The event row is already in. A reminder that won't attach costs if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
// that one alarm, not the event: throwing here would have the import
// count an event it did create as failed, and the retry would then
// skip it as a duplicate.
val attached = try {
resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder)
} catch (e: Exception) {
Log.w(TAG, "Reminder insert threw for imported event $eventId", e)
null
}
if (attached == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId") Log.w(TAG, "Failed to attach reminder ($minutes min) to imported event $eventId")
} }
} }
return eventId return eventId
} }
/** Provider DURATION for an imported recurring row: whole days / seconds. */
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${span / 86_400_000L}D" else "P${span / 1_000L}S"
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
}
/** The account a calendar belongs to, for scoping a `Colors` lookup. */ /** The account a calendar belongs to, for scoping a `Colors` lookup. */
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query( private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId), ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
@@ -1017,12 +936,10 @@ class AndroidCalendarDataSource @Inject constructor(
updated: EventForm, updated: EventForm,
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
) { ) {
val row = querySeriesRow(eventId)
val values = buildEventUpdateValues( val values = buildEventUpdateValues(
original = original, original = original,
updated = updated, updated = updated,
seriesDtStartMillis = row.dtStartMillis, seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis,
seriesExdate = row.exdate,
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
) )
if (values.isNotEmpty()) { if (values.isNotEmpty()) {
@@ -1234,22 +1151,9 @@ class AndroidCalendarDataSource @Inject constructor(
override fun updateOccurrence( override fun updateOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,
original: EventForm,
form: EventForm, form: EventForm,
allDayReminderTimeMinutes: Int, allDayReminderTimeMinutes: Int,
): Long { ): Long {
val row = querySeriesRow(eventId)
// Not a series, so there is no occurrence to single out: the row *is* the
// event, and an exception with nothing to be an exception to would leave
// an orphan holding the edit (the same test deleteOccurrence makes).
if (!row.recurs) {
updateEvent(eventId, original, form, allDayReminderTimeMinutes)
return eventId
}
// EXDATE needs no _sync_id; a modified exception does.
if (row.syncId == null) {
return detachOccurrence(eventId, beginMillis, row, form, allDayReminderTimeMinutes)
}
// The provider clones the series row and applies these values on top. // The provider clones the series row and applies these values on top.
val values = buildOccurrenceExceptionValues( val values = buildOccurrenceExceptionValues(
form = form, form = form,
@@ -1268,90 +1172,6 @@ class AndroidCalendarDataSource @Inject constructor(
return exceptionId return exceptionId
} }
/**
* "Edit only this event" on a series with **no `_sync_id`**: drop the
* occurrence from the parent with EXDATE and insert the edited values as a
* standalone event on the same calendar.
*
* A modified exception attaches to its parent only through `ORIGINAL_SYNC_ID`,
* exactly like the cancelled one [deleteOccurrence] documents; with no
* `_sync_id` the link never forms and the edit is lost (Codeberg #234).
* EXDATE plus a standalone row needs no link — what a detached instance
* degrades to without a `RECURRENCE-ID` to carry it.
*
* The detached row keeps no stored link back to its series, so: it no longer
* travels with it ([moveEvent] copies the master and its `ORIGINAL_ID`
* children, and this is neither); its EXDATE hole is an absolute instant, so
* re-timing the whole series brings the occurrence back beside the copy (a
* #47 delete resurrects the same way); and it is built from the form, not
* cloned, so `ORGANIZER`, `STATUS` and the attendee rows [reconcileAttendees]
* preserves are dropped — the same limitation as [moveEvent].
*
* Insert first, so a failure leaves the series untouched
* ([updateEventFromOccurrence]'s discipline); roll the new row back if the
* EXDATE update then fails, since it would be a visible duplicate. The
* reverse order risks the worse outcome — an excluded occurrence with no
* replacement, i.e. an edit that quietly deletes.
*/
private fun detachOccurrence(
eventId: Long,
beginMillis: Long,
row: SeriesRow,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
// Already detached (or deleted) from a stale screen still pointing at the
// parent: the EXDATE merge would fold the repeat away and still report a
// changed row, quietly leaving a *second* standalone copy.
if (exdateContains(row.exdate, beginMillis, row.allDay != 0, row.timezone)) {
throw NoSuchEventException(eventId)
}
// Reminders, guests and colour come along like any new event, and so does
// a fresh UID — the detached row is a separate event now, and sharing the
// parent's would collide with it in .ics restore dedup.
val detachedId = insertEvent(form.toDetachedOccurrence(), allDayReminderTimeMinutes)
val values = buildOccurrenceExdateValues(
existingExdate = row.exdate,
occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis,
rrule = row.rrule,
rdate = row.rdate,
duration = row.duration,
timezone = row.timezone,
allDay = row.allDay,
)
// Rows touched, not occurrences excluded — 1 whenever the series row still
// exists. It catches the row disappearing under us, not an EXDATE the
// provider's expansion fails to match.
val updatedRows = try {
resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
values.toContentValues(), null, null,
)
} catch (t: Throwable) {
rollBackDetached(detachedId)
throw t
}
if (updatedRows == 0) {
rollBackDetached(detachedId)
throw WriteFailedException(
"exdate occurrence for edit, event id=$eventId begin=$beginMillis",
)
}
return detachedId
}
/**
* Undo the standalone row [detachOccurrence] inserted before its EXDATE
* update failed. Best effort: the caller is already throwing, and the worst
* case is the duplicate we were avoiding — never a lost occurrence.
*/
private fun rollBackDetached(detachedId: Long) {
runCatching { deleteEvent(detachedId) }.onFailure {
Log.w(TAG, "Failed to roll back detached occurrence $detachedId", it)
}
}
override fun updateEventFromOccurrence( override fun updateEventFromOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,
@@ -1368,71 +1188,10 @@ class AndroidCalendarDataSource @Inject constructor(
} }
// Insert the new series first: if it fails, the original is untouched. // Insert the new series first: if it fails, the original is untouched.
val newEventId = insertEvent(updated, allDayReminderTimeMinutes) val newEventId = insertEvent(updated, allDayReminderTimeMinutes)
carrySplitExdate(newEventId, row, beginMillis, original, updated)
truncateSeries(eventId, row, beginMillis) truncateSeries(eventId, row, beginMillis)
return newEventId return newEventId
} }
/**
* Carry the [parent]'s exclusions for occurrences past [beginMillis] onto the
* series [newEventId] that now owns them, re-timed by the shift the split
* applied ([shiftedExdate]/[exdateAfter]). [insertEvent] builds the new row
* from the form, which knows nothing about them, so without this every
* occurrence the user had deleted from the tail of the series comes back.
*
* The whole time/recurrence set rides along with EXDATE for the reason
* [buildOccurrenceExdateValues] documents: on its own the provider does not
* read an EXDATE write as a recurrence change, and leaves the instances it
* expanded on insert standing.
*
* A failure rolls the new series back and throws, so the split fails whole
* rather than landing with the exclusions quietly dropped — the parent is
* still untruncated at this point, so the event is left exactly as it was.
*/
private fun carrySplitExdate(
newEventId: Long,
parent: SeriesRow,
beginMillis: Long,
original: EventForm,
updated: EventForm,
) {
// Dropping the recurrence in the same save leaves a one-off tail, and an
// exclusion means nothing on a row that doesn't recur.
if (updated.rrule.isNullOrBlank()) return
val carried = shiftedExdate(
existingExdate = exdateAfter(
parent.exdate,
beginMillis,
original.isAllDay,
parent.timezone,
),
original = original,
updated = updated,
zone = ZoneId.systemDefault(),
) ?: return
val row = querySeriesRow(newEventId)
val values = ContentValues().apply {
put(CalendarContract.Events.EXDATE, carried)
put(CalendarContract.Events.DTSTART, row.dtStartMillis)
put(CalendarContract.Events.RRULE, row.rrule)
put(CalendarContract.Events.DURATION, row.duration)
put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone)
put(CalendarContract.Events.ALL_DAY, row.allDay)
}
try {
val rows = resolver.update(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId),
values, null, null,
)
if (rows == 0) {
throw WriteFailedException("carry exdate onto split series id=$newEventId")
}
} catch (t: Throwable) {
runCatching { deleteEvent(newEventId) }
throw t
}
}
override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) { override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) {
val row = querySeriesRow(eventId) val row = querySeriesRow(eventId)
// From the first occurrence on = the whole series; also the fallback // From the first occurrence on = the whole series; also the fallback
@@ -1485,7 +1244,6 @@ class AndroidCalendarDataSource @Inject constructor(
CalendarContract.Events.ALL_DAY, CalendarContract.Events.ALL_DAY,
CalendarContract.Events._SYNC_ID, CalendarContract.Events._SYNC_ID,
CalendarContract.Events.EXDATE, CalendarContract.Events.EXDATE,
CalendarContract.Events.RDATE,
), ),
null, null, null, null, null, null,
)?.use { c -> )?.use { c ->
@@ -1498,7 +1256,6 @@ class AndroidCalendarDataSource @Inject constructor(
allDay = c.getInt(4), allDay = c.getInt(4),
syncId = c.getString(5), syncId = c.getString(5),
exdate = c.getString(6), exdate = c.getString(6),
rdate = c.getString(7),
) )
} else { } else {
null null
@@ -1514,16 +1271,7 @@ class AndroidCalendarDataSource @Inject constructor(
/** Null on a local calendar (and before a synced event's first push). */ /** Null on a local calendar (and before a synced event's first push). */
val syncId: String? = null, val syncId: String? = null,
val exdate: String? = null, val exdate: String? = null,
val rdate: String? = null,
) { ) {
/**
* Whether this row expands into more than itself. RDATE counts as much as
* RRULE: a sync adapter may list the occurrences instead of naming a rule,
* and the provider expands either — as does the read side, which marks
* such a row recurring too.
*/
val recurs: Boolean get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
/** 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(
beginMillis = beginMillis, beginMillis = beginMillis,
@@ -1714,12 +1462,6 @@ class AndroidCalendarDataSource @Inject constructor(
override fun deleteOccurrence(eventId: Long, beginMillis: Long) { override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
val row = querySeriesRow(eventId) val row = querySeriesRow(eventId)
// Not a series, so the row *is* the event: an exclusion would put a
// recurrence set on a one-off, an exception would leave an orphan.
if (!row.recurs) {
deleteEvent(eventId)
return
}
if (row.syncId == null) { if (row.syncId == null) {
// No _sync_id — a local calendar, or a synced event not pushed yet. // No _sync_id — a local calendar, or a synced event not pushed yet.
// A cancelled exception can only attach to its parent through // A cancelled exception can only attach to its parent through
@@ -1733,7 +1475,6 @@ class AndroidCalendarDataSource @Inject constructor(
occurrenceMillis = beginMillis, occurrenceMillis = beginMillis,
dtStartMillis = row.dtStartMillis, dtStartMillis = row.dtStartMillis,
rrule = row.rrule, rrule = row.rrule,
rdate = row.rdate,
duration = row.duration, duration = row.duration,
timezone = row.timezone, timezone = row.timezone,
allDay = row.allDay, allDay = row.allDay,
@@ -1850,12 +1591,5 @@ class AndroidCalendarDataSource @Inject constructor(
* next fires beyond it falls back to its series-start date. * next fires beyond it falls back to its series-start date.
*/ */
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000 const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
/**
* Tokens the SQL pre-filter is built from. Bounds the statement for a
* pasted paragraph; the ranker still requires every token, so a capped
* search returns fewer rows to check, never more hits.
*/
const val MAX_SEARCH_TOKENS = 8
} }
} }

View File

@@ -5,7 +5,6 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
@@ -18,11 +17,11 @@ interface CalendarRepository {
suspend fun eventDetail(eventId: Long): EventDetail suspend fun eventDetail(eventId: Long): EventDetail
/** /**
* Candidate matches for [query] with hidden calendars removed; empty when * Events whose title, description or location contains [query], with hidden
* [query] is blank. Searches the whole history/future and leaves matching * calendars removed and newest first. Empty when [query] is blank. Searches
* and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch]. * the whole history/future (see [CalendarDataSource.searchEvents]).
*/ */
suspend fun searchEvents(query: String): List<SearchCandidate> suspend fun searchEvents(query: String): List<EventInstance>
/** /**
* The event-colour palette a calendar's account publishes; empty when it * The event-colour palette a calendar's account publishes; empty when it
@@ -88,15 +87,9 @@ interface CalendarRepository {
/** /**
* 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`. [original] is what * form's values); returns the exception's `Events._ID`.
* the row holds now, for the case where it turns out not to recur.
*/ */
suspend fun updateOccurrence( suspend fun updateOccurrence(eventId: Long, beginMillis: Long, form: EventForm): Long
eventId: Long,
beginMillis: Long,
original: EventForm,
form: EventForm,
): Long
/** /**
* Change a recurring event from [beginMillis] onwards (series split); * Change a recurring event from [beginMillis] onwards (series split);

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.util.Log
import de.jeanlucmakiola.floret.time.toEpochMillis import de.jeanlucmakiola.floret.time.toEpochMillis
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
@@ -10,7 +9,6 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.SearchCandidate
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -27,7 +25,6 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Instant import kotlin.time.Instant
@@ -165,13 +162,13 @@ class CalendarRepositoryImpl @Inject constructor(
?: throw NoSuchEventException(eventId) ?: throw NoSuchEventException(eventId)
} }
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) { override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
if (query.isBlank()) return@withContext emptyList() if (query.isBlank()) return@withContext emptyList()
val excluded = prefs.hiddenCalendarIds.first() + val excluded = prefs.hiddenCalendarIds.first() +
prefs.pendingDisabledCalendarIds.first() + prefs.pendingDisabledCalendarIds.first() +
invisibleCalendarIds() invisibleCalendarIds()
dataSource.searchEvents(query) dataSource.searchEvents(query)
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } } .let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
} }
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> = override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
@@ -212,60 +209,26 @@ class CalendarRepositoryImpl @Inject constructor(
} }
override suspend fun exportEvents(calendarIds: Set<Long>?) = override suspend fun exportEvents(calendarIds: Set<Long>?) =
withContext(io) { dataSource.exportableEvents(calendarIds, allDayReminderTimeMinutes()) } withContext(io) { dataSource.exportableEvents(calendarIds) }
override suspend fun importEvents( override suspend fun importEvents(
targetCalendarId: Long, targetCalendarId: Long,
events: List<ParsedIcsEvent>, events: List<ParsedIcsEvent>,
): IcsImportSummary = withContext(io) { ): IcsImportSummary = withContext(io) {
val existing = dataSource.existingUids(targetCalendarId) val existing = dataSource.existingUids(targetCalendarId)
// Both are per-calendar, not per-event: looking them up once keeps a
// thousand-event restore to two extra queries. The palette is the
// uncurated one — an imported colour is matched against every key the
// account accepts, not the subset the picker shows.
val palette = dataSource.publishedEventColors(targetCalendarId)
val allDayMinutes = allDayReminderTimeMinutes()
var imported = 0 var imported = 0
var skipped = 0 var skipped = 0
var failed = 0 for (event in events) {
var notAttempted = 0
for ((index, event) in events.withIndex()) {
// A known UID means the event is already in this calendar — skip, // A known UID means the event is already in this calendar — skip,
// keeping a restore idempotent (no overwrite this pass). // keeping a restore idempotent (no overwrite this pass).
if (event.uid != null && event.uid in existing) { if (event.uid != null && event.uid in existing) {
skipped++ skipped++
continue } else {
} dataSource.insertImportedEvent(event, targetCalendarId)
try {
dataSource.insertImportedEvent(event, targetCalendarId, allDayMinutes, palette)
imported++ imported++
} catch (e: CancellationException) {
throw e
} catch (e: SecurityException) {
// Not this event's fault: the permission or the target calendar is
// gone, so every remaining insert would fail the same way.
Log.w(TAG, "Import stopped: the calendar is no longer writable", e)
// A known UID would have been skipped whatever happened, so it is
// no part of what a retry still has to import.
val remaining = events.drop(index)
val duplicates = remaining.count { it.uid?.let { uid -> uid in existing } == true }
skipped += duplicates
notAttempted = remaining.size - duplicates
break
} catch (e: Exception) {
// One event the provider won't take must not cost the user the
// rest of the file — foreign exports do carry rows it rejects
// outright (a malformed RRULE throws straight out of insert).
failed++
Log.w(TAG, "Skipped an unimportable event", e)
} }
} }
IcsImportSummary( IcsImportSummary(imported = imported, skippedDuplicate = skipped)
imported = imported,
skippedDuplicate = skipped,
failed = failed,
notAttempted = notAttempted,
)
} }
override suspend fun createEvent(form: EventForm): Long = withContext(io) { override suspend fun createEvent(form: EventForm): Long = withContext(io) {
@@ -298,12 +261,9 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun updateOccurrence( override suspend fun updateOccurrence(
eventId: Long, eventId: Long,
beginMillis: Long, beginMillis: Long,
original: EventForm,
form: EventForm, form: EventForm,
): Long = withContext(io) { ): Long = withContext(io) {
dataSource.updateOccurrence( dataSource.updateOccurrence(eventId, beginMillis, form, allDayReminderTimeMinutes())
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
)
} }
override suspend fun updateEventFromOccurrence( override suspend fun updateEventFromOccurrence(
@@ -327,10 +287,6 @@ class CalendarRepositoryImpl @Inject constructor(
) = withContext(io) { ) = withContext(io) {
dataSource.deleteEventFromOccurrence(eventId, beginMillis) dataSource.deleteEventFromOccurrence(eventId, beginMillis)
} }
private companion object {
const val TAG = "CalendarRepository"
}
} }
private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow { private fun <T> Flow<Unit>.reQuery(block: suspend () -> T): Flow<T> = flow {

View File

@@ -3,23 +3,14 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.nearestTo
import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDate
import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toJavaLocalDateTime
import java.time.Duration 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.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.ResolverStyle
import java.time.LocalDate as JavaLocalDate
import java.time.LocalDateTime as JavaLocalDateTime import java.time.LocalDateTime as JavaLocalDateTime
import java.time.LocalTime as JavaLocalTime
/** 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(
@@ -143,21 +134,18 @@ 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, RRULE and EXDATE cleared; * - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared;
* - recurring result → the *series* DTSTART moves by the same **wall-clock** * - recurring result → the *series* DTSTART moves by the same **wall-clock**
* shift the user applied to the displayed occurrence and is re-resolved in the * shift the user applied to the displayed occurrence and is re-resolved in the
* event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION * event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION
* replaces DTEND, RRULE is written, and the row's exclusions * replaces DTEND, RRULE is written. This keeps past occurrences intact when
* ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past * someone edits a later occurrence's time, and keeps the anchor's time-of-day
* occurrences intact when someone edits a later occurrence's time, and keeps * stable across a DST boundary or a zone change between the two.
* 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,
updated: EventForm, updated: EventForm,
seriesDtStartMillis: Long, seriesDtStartMillis: Long,
seriesExdate: String?,
zone: ZoneId, zone: ZoneId,
): Map<String, Any?> = buildMap { ): Map<String, Any?> = buildMap {
if (updated.title.trim() != original.title.trim()) { if (updated.title.trim() != original.title.trim()) {
@@ -197,10 +185,6 @@ internal fun buildEventUpdateValues(
put(CalendarContract.Events.DTEND, newTimes.dtEndMillis) put(CalendarContract.Events.DTEND, newTimes.dtEndMillis)
put(CalendarContract.Events.RRULE, null) put(CalendarContract.Events.RRULE, null)
put(CalendarContract.Events.DURATION, null) put(CalendarContract.Events.DURATION, null)
// The exclusions named occurrences of a series that no longer exists.
// Left behind they are dormant rather than harmless: adding a recurrence
// back later would punch the old holes into the new one.
put(CalendarContract.Events.EXDATE, null)
} else { } else {
// Move the series anchor by the *wall-clock* shift the user applied to the // Move the series anchor by the *wall-clock* shift the user applied to the
// displayed occurrence, then re-resolve it in the event's (possibly new) // displayed occurrence, then re-resolve it in the event's (possibly new)
@@ -222,14 +206,6 @@ internal fun buildEventUpdateValues(
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))
// An EXDATE stamp is an absolute instant, so it excludes an occurrence
// only while the series keeps generating one at exactly that instant. The
// anchor has just moved, so every occurrence regenerates elsewhere and a
// stamp left behind matches none of them — the occurrence the user deleted
// comes back. Move the stamps the same way the anchor moved.
shiftedExdate(seriesExdate, original, updated, zone)
?.takeIf { it != seriesExdate }
?.let { put(CalendarContract.Events.EXDATE, it) }
} }
} }
@@ -267,21 +243,6 @@ internal fun buildOccurrenceExceptionValues(
putAll(eventColorColumns(form.colorKey, form.color)) putAll(eventColorColumns(form.colorKey, form.color))
} }
/**
* The form as a **detached occurrence**: the same edited values, with the
* series rule dropped so [buildEventInsertValues] writes a standalone one-off
* row (DTSTART + DTEND, no RRULE/DURATION) at the occurrence's own times.
*
* The "edit only this event" shape for a series with **no `_sync_id`**, where an
* exception row can't attach to its parent at all (Codeberg #234).
*
* The exception path gets the rule dropped for free — the provider clears the
* RRULE it cloned when an exception carries DTSTART + DURATION
* ([buildOccurrenceExceptionValues]). Here nothing is cloned, so it is stripped
* by hand; leaving it on would insert a second *series* overlapping the first.
*/
internal fun EventForm.toDetachedOccurrence(): EventForm = copy(rrule = null)
/** /**
* Raw provider snapshot of a master/one-off Events row, enough to re-insert it * 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` * verbatim on another calendar (a calendar move is copy+delete — `CALENDAR_ID`
@@ -445,336 +406,52 @@ internal fun buildOccurrenceCancelValues(
* first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what * first occurrence. Passing DTSTART + DURATION + RRULE + zone together is what
* re-expands it correctly. All observed on a Pixel; see the #47 notes. * re-expands it correctly. All observed on a Pixel; see the #47 notes.
* *
* [rdate] travels with [rrule]: a sync adapter may list a series' occurrences
* instead of naming a rule, and rewriting the recurrence set without it would
* leave the provider nothing but DTSTART to re-expand from — the collapse this
* values map exists to avoid.
*
* EXDATE is a comma-separated list, so an existing one is appended to (a repeat * EXDATE is a comma-separated list, so an existing one is appended to (a repeat
* of the same occurrence is folded away — [mergedExdate]). * 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( internal fun buildOccurrenceExdateValues(
existingExdate: String?, existingExdate: String?,
occurrenceMillis: Long, occurrenceMillis: Long,
dtStartMillis: Long, dtStartMillis: Long,
rrule: String?, rrule: String?,
rdate: String?,
duration: String?, duration: String?,
timezone: String?, timezone: String?,
allDay: Int, allDay: Int,
): Map<String, Any?> = mapOf( ): Map<String, Any?> {
CalendarContract.Events.EXDATE to val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0)
mergedExdate(existingExdate, occurrenceMillis, allDay != 0, timezone), 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.DTSTART to dtStartMillis,
CalendarContract.Events.RRULE to rrule, CalendarContract.Events.RRULE to rrule,
CalendarContract.Events.RDATE to rdate,
CalendarContract.Events.DURATION to duration, CalendarContract.Events.DURATION to duration,
CalendarContract.Events.EVENT_TIMEZONE to timezone, CalendarContract.Events.EVENT_TIMEZONE to timezone,
CalendarContract.Events.ALL_DAY to allDay, CalendarContract.Events.ALL_DAY to allDay,
) )
/**
* [existingExdate] with the occurrence at [occurrenceMillis] excluded as well.
*
* Existing stamps are canonicalised first — all-day series take the `VALUE=DATE`
* form (`yyyyMMdd`), timed ones the UTC date-time form (`yyyyMMddTHHmmssZ`) — so
* appending to a list a sync adapter wrote in the other accepted form folds the
* repeat away instead of leaving the same day excluded twice in two spellings.
*
* A stamp in no form Calendula reads makes the whole list untouchable: it is kept
* exactly as it is, behind the parameter prefix that tells a reader how to
* resolve it, and the new exclusion joins it in the list's own form.
* Canonicalising the readable stamps around it would leave absolute stamps under
* a prefix claiming a zone for all of them — which is how the unreadable one
* quietly changes instant.
*/
private fun mergedExdate(
existingExdate: String?,
occurrenceMillis: Long,
isAllDay: Boolean,
timezone: String?,
): String {
val listZone = exdateListZone(existingExdate, timezone)
val stamps = exdateStamps(existingExdate)
val canonical = stamps.map { canonicalExdateStamp(it, isAllDay, listZone) }
if (canonical.all { it != null }) {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
return (canonical.filterNotNull() + stamp).distinct().joinToString(",")
} }
val prefix = existingExdate?.exdateParameterPrefix().orEmpty()
val stamp = if (isAllDay || prefix.isEmpty()) {
formatExdateStamp(occurrenceMillis, isAllDay)
} else {
Instant.ofEpochMilli(occurrenceMillis).atZone(listZone).format(FLOATING_EXDATE)
}
return prefix + (stamps + stamp).distinct().joinToString(",")
}
/**
* Whether [existingExdate] already excludes the occurrence at [occurrenceMillis]
* — i.e. it has already been dropped from the series, deleted or detached.
*
* Guards the detach path against running twice from a stale screen: the EXDATE
* merge folds the repeat away silently and the update still reports one row
* changed, so a second save would leave a second standalone copy.
*/
internal fun exdateContains(
existingExdate: String?,
occurrenceMillis: Long,
isAllDay: Boolean,
timezone: String?,
): Boolean {
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
val listZone = exdateListZone(existingExdate, timezone)
// Canonicalised rather than compared verbatim: a sync adapter may spell the
// same exclusion differently to Calendula, and a guard that misses it lets
// the detach run a second time and leave a second standalone copy.
return exdateStamps(existingExdate)
.any { canonicalExdateStamp(it, isAllDay, listZone) == stamp }
}
/**
* [existingExdate] with every stamp re-timed by the same **wall-clock** shift the
* series anchor takes from [original] to [updated] — the exclusions' half of the
* anchor move [buildEventUpdateValues] performs, and what carries them onto the
* new series when "this and following" splits one.
*
* A stamp is an absolute instant, so it keeps naming its occurrence only if it
* moves exactly as the occurrence does: shifted in wall clock and re-resolved in
* the event's (possibly new) zone. A millisecond delta would instead bake in the
* offset that happened to apply at the edited occurrence — an hour off for any
* exclusion on the far side of a DST boundary. All-day-ness is read from
* [original] and written from [updated], so the `VALUE=DATE` and date-time forms
* convert into each other when the event switches.
*
* A zoned list is read in the zone its prefix names ([exdateListZone]). Null
* when there is nothing to carry: no stamps, or a stamp in a form Calendula
* cannot read at all. Those are left exactly as they are rather than guessed at —
* a stale stamp excludes nothing, but a mangled one could exclude the wrong
* occurrence.
*/
internal fun shiftedExdate(
existingExdate: String?,
original: EventForm,
updated: EventForm,
zone: ZoneId,
): String? {
val stamps = exdateStamps(existingExdate)
if (stamps.isEmpty()) return null
val fromZone = original.writeZone(zone)
val toZone = updated.writeZone(zone)
val listZone = exdateListZone(existingExdate, fromZone.id)
val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal())
return stamps
.map { stamp ->
val local = parseExdateStamp(stamp, original.isAllDay, fromZone, listZone)
?: return null
formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone)
}
.distinct()
.joinToString(",")
}
/**
* The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones
* that belong to the *new* series once "this and following" splits a recurring
* event there. The parent keeps the full list; its stamps past the split point are
* simply inert once it stops generating those occurrences.
*
* The occurrence at [beginMillis] itself is never carried. It is the one the user
* is editing, so it exists by definition, and honouring a stale exclusion for it
* would swallow the edit whole.
*
* The stamps that qualify come back canonicalised, since the zone prefix that
* told the reader how to resolve them does not survive the filter.
*
* Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]).
*/
internal fun exdateAfter(
existingExdate: String?,
beginMillis: Long,
isAllDay: Boolean,
timezone: String?,
): String? {
val stamps = exdateStamps(existingExdate)
if (stamps.isEmpty()) return null
val listZone = exdateListZone(existingExdate, timezone)
return stamps
.mapNotNull { stamp ->
val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone) ?: return null
utc.takeIf { it.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis }
}
.map { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
.takeIf { it.isNotEmpty() }
?.joinToString(",")
}
/**
* The individual stamps of an EXDATE column value; it is a comma-separated list,
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
* a zoned one ([exdateListZone] reads that half).
*/
private fun exdateStamps(exdate: String?): List<String> = exdate
?.withoutExdateParameters()
?.split(',')
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
/**
* An EXDATE column value with the parameter section its stamps sit behind taken
* off.
*/
internal fun String.withoutExdateParameters(): String = substring(exdateParameterPrefix().length)
/**
* The parameter section an EXDATE list's stamps sit behind, its `;` included, or
* empty when it has none. Only a `;` before the first stamp separator is one —
* anything later belongs to a stamp, and cutting there would drop every stamp in
* front of it.
*/
private fun String.exdateParameterPrefix(): String {
val semicolon = indexOf(';')
val comma = indexOf(',')
return if (semicolon >= 0 && (comma < 0 || semicolon < comma)) {
substring(0, semicolon + 1)
} else {
""
}
}
/**
* The zone the floating stamps of [exdate] are wall clock in: the one its
* parameter prefix names, or [fallback] — the event's own `EVENT_TIMEZONE` —
* when there is no prefix, or the device's tz database doesn't know the name it
* uses (Exchange spells zones its own way). The series' own zone is the right
* guess, since a zoned exclusion list is written in it. UTC last, for a row that
* carries no zone either. Stamps already written as UTC instants ignore all this.
*
* Both spellings of the prefix are read. AOSP's `RecurrenceSet` stores the TZID
* parameter's *value* alone (`Europe/Berlin;<stamps>`), and so does DAVx5; the
* iCalendar `TZID=<zone>;` some writers keep is the other one. Only matching the
* second would miss every list the provider itself wrote.
*/
internal fun exdateListZone(exdate: String?, fallback: String?): ZoneId =
exdate?.exdateZoneName()?.toZoneIdOrNull()
?: fallback?.toZoneIdOrNull()
?: ZoneOffset.UTC
/** The zone id an EXDATE list's parameter prefix names, with or without `TZID=`. */
private fun String.exdateZoneName(): String {
val prefix = exdateParameterPrefix().dropLast(1)
return if (prefix.startsWith(TZID_PREFIX, ignoreCase = true)) {
prefix.drop(TZID_PREFIX.length)
} else {
prefix
}
}
private fun String.toZoneIdOrNull(): ZoneId? = runCatching { ZoneId.of(this) }.getOrNull()
/** The optional `TZID=` a zoned EXDATE list's zone id may sit behind. */
private const val TZID_PREFIX = "TZID="
/** /**
* One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms * 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 * are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date
* reads off the UTC calendar day. * reads off the UTC calendar day.
*/ */
private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String = private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String {
formatExdateStamp( val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC)
local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(), return if (isAllDay) {
isAllDay = isAllDay, "%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth)
zone = ZoneOffset.UTC, } else {
"%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.monthValue, utc.dayOfMonth,
utc.hour, utc.minute, utc.second,
) )
/**
* One EXDATE entry for the occurrence whose wall clock in [zone] is [local]. An
* all-day entry keeps only the date (its time-of-day is the anchor's, not the
* occurrence's); a timed one is resolved in [zone] and written as a UTC instant.
*/
private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone: ZoneId): String =
if (isAllDay) {
local.toLocalDate().format(ALL_DAY_EXDATE)
} else {
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE)
} }
/**
* [stamp] as the wall clock it names in [zone] — the inverse of
* [formatExdateStamp]. Null for anything Calendula cannot read, so a caller can
* tell "not ours, leave it alone" from a value it may safely re-time.
*
* A timed exclusion is read both as the UTC instant Calendula writes and as a
* floating stamp, which [listZone] then resolves. An all-day one is read both
* bare (`yyyyMMdd`, what Calendula writes) and padded to a midnight stamp, in
* either spelling: sync adapters are inconsistent about which they store — the
* same forms [exportExDates] reshapes on the way out — and refusing one would
* drop every exclusion of a foreign all-day series the moment it is re-timed.
*/
private fun parseExdateStamp(
stamp: String,
isAllDay: Boolean,
zone: ZoneId,
listZone: ZoneId,
): JavaLocalDateTime? =
if (isAllDay) {
parseAllDayExdate(stamp)?.atStartOfDay()
} else {
parseTimedExdate(stamp, listZone)?.withZoneSameInstant(zone)?.toLocalDateTime()
} }
/**
* A timed EXDATE stamp as the instant it names: the trailing `Z` form resolved in
* UTC, or the floating one resolved in [listZone].
*/
private fun parseTimedExdate(stamp: String, listZone: ZoneId): ZonedDateTime? =
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE).atZone(ZoneOffset.UTC) }.getOrNull()
?: runCatching {
JavaLocalDateTime.parse(stamp, FLOATING_EXDATE).atZone(listZone)
}.getOrNull()
/**
* The calendar day an all-day EXDATE stamp names, bare or padded to a midnight
* stamp. A padded stamp naming any other time of day is refused rather than
* rounded: it is not a form an all-day series is written in, so it could be
* naming something else entirely. Midnight is midnight in any zone, so the
* padding's own spelling — trailing `Z` or floating — makes no difference.
*/
private fun parseAllDayExdate(stamp: String): JavaLocalDate? {
runCatching { JavaLocalDate.parse(stamp, ALL_DAY_EXDATE) }.getOrNull()?.let { return it }
val padded = parsePaddedExdate(stamp) ?: return null
return padded.toLocalDate().takeIf { padded.toLocalTime() == JavaLocalTime.MIDNIGHT }
}
/** An `uuuuMMddTHHmmss` stamp, with or without the trailing `Z`, as its own wall clock. */
private fun parsePaddedExdate(stamp: String): JavaLocalDateTime? =
runCatching { JavaLocalDateTime.parse(stamp, TIMED_EXDATE) }.getOrNull()
?: runCatching { JavaLocalDateTime.parse(stamp, FLOATING_EXDATE) }.getOrNull()
/**
* [stamp] in the form Calendula writes, so two spellings of the same exclusion
* compare equal. Null for a stamp in no form it reads.
*/
internal fun canonicalExdateStamp(
stamp: String,
isAllDay: Boolean,
listZone: ZoneId,
): String? = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC, listZone)
?.let { formatExdateStamp(it, isAllDay, ZoneOffset.UTC) }
/** `VALUE=DATE` EXDATE form, for an all-day series. */
private val ALL_DAY_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT)
/** UTC date-time EXDATE form, for a timed series. */
private val TIMED_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT)
/** The same form without the `Z`, wall clock in the list's zone rather than UTC. */
private val FLOATING_EXDATE: DateTimeFormatter =
DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss").withResolverStyle(ResolverStyle.STRICT)
/** /**
* 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
@@ -822,73 +499,3 @@ internal fun AccessLevel.toProviderValue(): Int = when (this) {
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
} }
/**
* The `Events` row for a `.ics` event being imported into [calendarId].
*
* A recurring row carries RRULE + DURATION (and any EXDATE) with no DTEND; a
* one-off carries DTEND. All-day rows live at UTC midnights, exactly as the
* parser hands them over.
*
* [palette] is the target account's published event colours. A calendar that
* publishes one rejects a raw `EVENT_COLOR`, so an imported colour — which is
* an arbitrary ARGB from a foreign app — is snapped to the nearest key it does
* accept; accounts with no palette take the raw value (see [eventColorColumns]).
*/
internal fun buildImportedEventValues(
event: ParsedIcsEvent,
calendarId: Long,
uid: String,
palette: List<EventColorOption>,
): Map<String, Any?> = buildMap {
val startMillis = event.start.toEpochMilliseconds()
val endMillis = event.end.toEpochMilliseconds()
put(CalendarContract.Events.CALENDAR_ID, calendarId)
put(CalendarContract.Events.UID_2445, uid)
put(CalendarContract.Events.TITLE, event.summary.trim())
put(CalendarContract.Events.ALL_DAY, if (event.isAllDay) 1 else 0)
put(CalendarContract.Events.DTSTART, startMillis)
if (event.recurrenceRule == null) {
put(CalendarContract.Events.DTEND, endMillis)
} else {
put(CalendarContract.Events.RRULE, event.recurrenceRule)
put(
CalendarContract.Events.DURATION,
importDuration(startMillis, endMillis, event.isAllDay),
)
event.exDates.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EXDATE, it.joinToString(",")) }
}
// All-day rows live at UTC midnights (the file already encodes them so);
// timed rows keep the event's own zone.
put(CalendarContract.Events.EVENT_TIMEZONE, if (event.isAllDay) "UTC" else event.zoneId)
put(CalendarContract.Events.AVAILABILITY, event.availability.toProviderValue())
put(CalendarContract.Events.STATUS, event.status.toProviderStatus())
event.location?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
event.description?.trim()?.takeIf { it.isNotEmpty() }
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
event.color?.let { color ->
val key = palette.nearestTo(color)?.key
putAll(eventColorColumns(colorKey = key, color = if (key == null) color else null))
}
}
/**
* Provider `DURATION` for an imported recurring row: whole days for an all-day
* series, seconds otherwise. An all-day series is never shorter than a day —
* the provider expands a zero-length one into no instances at all, so the
* series would vanish (which is what a literal read of the Fossify family's
* inclusive DTEND used to produce; see "Importing foreign .ics" in
* docs/ARCHITECTURE.md).
*/
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
EventStatus.Confirmed -> CalendarContract.Events.STATUS_CONFIRMED
EventStatus.Tentative -> CalendarContract.Events.STATUS_TENTATIVE
EventStatus.Cancelled -> CalendarContract.Events.STATUS_CANCELED
}

View File

@@ -5,9 +5,6 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
/** /**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent] * Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
@@ -15,23 +12,14 @@ import java.time.ZoneOffset
* [calendarName] the display name of its calendar (emitted as * [calendarName] the display name of its calendar (emitted as
* `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with * `X-CALENDULA-CALENDAR`). Pure given a [ColumnReader] — JVM-tested with
* MapColumnReader. * MapColumnReader.
*
* An all-day row's raw offset has the firing time of day encoded into it and is
* normally *negative* (see [AllDayReminderEncoding]); written out literally it
* would be dropped as a trigger that fires after the event, losing the reminder.
* [allDayReminderTimeMinutes] and [zone] decode it back to the whole-day lead
* time the file should carry, which an import re-encodes for its own device.
*/ */
internal fun ColumnReader.toIcsEvent( internal fun ColumnReader.toIcsEvent(
reminderMinutes: List<Int>, reminderMinutes: List<Int>,
calendarName: String?, calendarName: String?,
allDayReminderTimeMinutes: Int,
zone: ZoneId = ZoneId.systemDefault(),
): IcsEvent { ): IcsEvent {
val eventId = getLong(EventExportProjection.IDX_ID) val eventId = getLong(EventExportProjection.IDX_ID)
val dtStart = getLong(EventExportProjection.IDX_DTSTART) val dtStart = getLong(EventExportProjection.IDX_DTSTART)
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() } val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it // Recurring rows store DURATION instead of DTEND; reconstruct the end from it
// so the writer can render DTEND. A missing/blank both means a zero-length event. // so the writer can render DTEND. A missing/blank both means a zero-length event.
@@ -52,77 +40,16 @@ internal fun ColumnReader.toIcsEvent(
summary = getString(EventExportProjection.IDX_TITLE).orEmpty(), summary = getString(EventExportProjection.IDX_TITLE).orEmpty(),
start = dtStart.toKotlinInstantFromEpochMillis(), start = dtStart.toKotlinInstantFromEpochMillis(),
end = end.toKotlinInstantFromEpochMillis(), end = end.toKotlinInstantFromEpochMillis(),
isAllDay = isAllDay, isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0,
zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() } zoneId = getString(EventExportProjection.IDX_EVENT_TIMEZONE)?.takeIf { it.isNotBlank() }
?: "UTC", ?: "UTC",
recurrenceRule = rrule, recurrenceRule = rrule,
exDates = if (rrule == null) {
emptyList()
} else {
exportExDates(
getString(EventExportProjection.IDX_EXDATE),
isAllDay,
getString(EventExportProjection.IDX_EVENT_TIMEZONE),
)
},
location = getString(EventExportProjection.IDX_LOCATION), location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION), description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = if (isAllDay) { reminderMinutes = reminderMinutes,
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
}
} else {
reminderMinutes
},
status = status, status = status,
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)), availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName, calendarName = calendarName,
) )
} }
/**
* The row's `EXDATE` as the writer wants it: one stamp per entry, empties
* dropped.
*
* An all-day exclusion names a calendar day, and sync adapters are inconsistent
* about whether they write it as a bare `yyyyMMdd` or pad it to a midnight
* stamp; the time part is dropped so the exported value is the day either way.
* Calendula's own writes ([buildOccurrenceExdateValues]) are already bare.
*
* A zoned exclusion arrives in AOSP's `RecurrenceSet` form, `<zone>;<stamps>`,
* where the prefix names the zone the whole list is wall clock in. [IcsWriter]
* emits one bare `EXDATE:` line, and a parameter belongs before that colon, not
* inside the value — passed on as-is it would make the line unparseable. The
* prefix is therefore taken off and its stamps resolved to UTC instants
* ([canonicalExdateStamp], the same reading the write path does), the form every
* reader accepts.
*
* Which zone that resolution uses is [exdateListZone]'s call, the same one the
* write path makes: the `TZID=` when this device's tz database knows the name,
* else the row's own [eventTimezone]. A stamp is never emitted floating just
* because the prefix named a zone we couldn't place — against a `DTSTART;TZID=…`
* line every reader would resolve it in its own zone and the exclusion would stop
* matching its occurrence.
*/
internal fun exportExDates(
exdate: String?,
isAllDay: Boolean,
eventTimezone: String? = null,
): List<String> {
if (exdate.isNullOrBlank()) return emptyList()
val zone = exdateListZone(exdate, eventTimezone)
return exdate
.withoutExdateParameters()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { stamp ->
if (isAllDay) {
stamp.substringBefore('T')
} else {
canonicalExdateStamp(stamp, isAllDay = false, listZone = zone) ?: stamp
}
}
.distinct()
}

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.provider.CalendarContract
import android.util.Log import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
@@ -39,7 +38,5 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0, isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
color = color, color = color,
location = getString(InstanceProjection.IDX_LOCATION), location = getString(InstanceProjection.IDX_LOCATION),
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
) )
} }

View File

@@ -52,7 +52,6 @@ internal object InstanceProjection {
CalendarContract.Instances.EVENT_COLOR, CalendarContract.Instances.EVENT_COLOR,
CalendarContract.Instances.CALENDAR_COLOR, CalendarContract.Instances.CALENDAR_COLOR,
CalendarContract.Instances.EVENT_LOCATION, CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.SELF_ATTENDEE_STATUS,
) )
const val IDX_INSTANCE_ID = 0 const val IDX_INSTANCE_ID = 0
@@ -65,7 +64,6 @@ internal object InstanceProjection {
const val IDX_EVENT_COLOR = 7 const val IDX_EVENT_COLOR = 7
const val IDX_CALENDAR_COLOR = 8 const val IDX_CALENDAR_COLOR = 8
const val IDX_LOCATION = 9 const val IDX_LOCATION = 9
const val IDX_SELF_ATTENDEE_STATUS = 10
} }
internal object EventDetailProjection { internal object EventDetailProjection {
@@ -121,12 +119,10 @@ internal object EventDetailProjection {
/** /**
* Master/one-off Events rows for a whole-calendar backup. Unlike * Master/one-off Events rows for a whole-calendar backup. Unlike
* [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across * [EventDetailProjection] this reads `UID_2445` (to keep a row's identity across
* backups) and `DURATION` (recurring rows carry it instead of DTEND), plus * backups) and `DURATION` (recurring rows carry it instead of DTEND). Modified-
* `EXDATE` so a series exports the occurrences deleted from it. Modified-
* occurrence and cancelled-exception rows are filtered out by the query * occurrence and cancelled-exception rows are filtered out by the query
* (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides aren't exported — a * (`ORIGINAL_ID IS NULL`), so RECURRENCE-ID overrides and EXDATEs aren't
* documented v1 limit (import skips them too). EXDATE is unaffected by that * exported yet — a documented v1 limit (import skips them too).
* filter: it lives on the master row, not on an exception of its own.
*/ */
internal object EventExportProjection { internal object EventExportProjection {
val COLUMNS: Array<String> = arrayOf( val COLUMNS: Array<String> = arrayOf(
@@ -139,7 +135,6 @@ internal object EventExportProjection {
CalendarContract.Events.ALL_DAY, CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_TIMEZONE, CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.RRULE, CalendarContract.Events.RRULE,
CalendarContract.Events.EXDATE,
CalendarContract.Events.EVENT_LOCATION, CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.DESCRIPTION, CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.STATUS, CalendarContract.Events.STATUS,
@@ -156,12 +151,11 @@ internal object EventExportProjection {
const val IDX_ALL_DAY = 6 const val IDX_ALL_DAY = 6
const val IDX_EVENT_TIMEZONE = 7 const val IDX_EVENT_TIMEZONE = 7
const val IDX_RRULE = 8 const val IDX_RRULE = 8
const val IDX_EXDATE = 9 const val IDX_LOCATION = 9
const val IDX_LOCATION = 10 const val IDX_DESCRIPTION = 10
const val IDX_DESCRIPTION = 11 const val IDX_STATUS = 11
const val IDX_STATUS = 12 const val IDX_AVAILABILITY = 12
const val IDX_AVAILABILITY = 13 const val IDX_CALENDAR_ID = 13
const val IDX_CALENDAR_ID = 14
} }
/** /**
@@ -186,9 +180,6 @@ internal object SearchProjection {
// display its nearest occurrence, not the series-start DTSTART. // display its nearest occurrence, not the series-start DTSTART.
CalendarContract.Events.RRULE, CalendarContract.Events.RRULE,
CalendarContract.Events.RDATE, CalendarContract.Events.RDATE,
// Excerpted, not just filtered on: a hit has to show what it matched.
CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
) )
const val IDX_ID = 0 const val IDX_ID = 0
@@ -203,8 +194,6 @@ internal object SearchProjection {
const val IDX_LOCATION = 9 const val IDX_LOCATION = 9
const val IDX_RRULE = 10 const val IDX_RRULE = 10
const val IDX_RDATE = 11 const val IDX_RDATE = 11
const val IDX_DESCRIPTION = 12
const val IDX_SELF_ATTENDEE_STATUS = 13
} }
/** /**

View File

@@ -1,24 +0,0 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
/**
* "Fire at whatever lead time this account defaults to" — a sentinel in the
* `Reminders.MINUTES` column, not an offset, so nothing may do arithmetic on it.
* `CalendarContract` never exposes what an account resolves it to, so Calendula
* neither fires such a row ([withoutProviderDefaults]) nor decodes it
* ([fromProviderAllDayMinutes]); the detail screen names it instead.
*
* It is the **only** negative that means this. A real all-day offset is often
* negative too — the firing time of day is encoded into it — so a `< 0` test
* would silence reminders that do fire.
*/
internal const val MINUTES_DEFAULT = CalendarContract.Reminders.MINUTES_DEFAULT
/**
* Raw provider offsets with the account-default rows dropped, so the pure
* planner ([de.jeanlucmakiola.calendula.domain.reminders.planReminders]) only
* ever sees offsets it can resolve.
*/
internal fun Map<Long, List<Int>>.withoutProviderDefaults(): Map<Long, List<Int>> =
mapValues { (_, minutes) -> minutes.filter { it != MINUTES_DEFAULT } }

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
@@ -43,7 +42,5 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
location = getString(SearchProjection.IDX_LOCATION), location = getString(SearchProjection.IDX_LOCATION),
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() || isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(), !getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
) )
} }

View File

@@ -301,19 +301,6 @@ class SettingsPrefs @Inject constructor(
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled } store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
} }
/**
* Whether events can be dragged to another slot in the calendar views (#68,
* #173). Defaults to ON. Off means no block registers a drag gesture at all;
* rescheduling then goes through the edit form.
*/
val dragToReschedule: Flow<Boolean> = store.data.map { prefs ->
prefs[DRAG_TO_RESCHEDULE_KEY] ?: true
}
suspend fun setDragToReschedule(enabled: Boolean) {
store.edit { it[DRAG_TO_RESCHEDULE_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
@@ -563,87 +550,8 @@ class SettingsPrefs @Inject constructor(
prefs[REMINDER_ONBOARDING_KEY] ?: false prefs[REMINDER_ONBOARDING_KEY] ?: false
} }
/** Cleared, not just set: the wizard's back button re-asks a step (#163). */ suspend fun setReminderOnboardingDone() {
suspend fun setReminderOnboardingDone(done: Boolean = true) { store.edit { it[REMINDER_ONBOARDING_KEY] = true }
store.edit { it[REMINDER_ONBOARDING_KEY] = done }
}
/**
* Whether this install is a fresh one still owing the wizard's optional
* steps (#163). Armed on any launch that holds the calendar permission and
* has not finished the reminder step, and cleared again by
* [finishOnboardingWizard], so an existing user who revokes and re-grants
* the permission isn't re-onboarded.
*/
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
}
suspend fun armOnboardingWizard() {
store.edit { prefs ->
if (prefs[REMINDER_ONBOARDING_KEY] != true) prefs[ONBOARDING_WIZARD_ARMED_KEY] = true
}
}
/** Whether the wizard's backup step has been answered (or skipped). */
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_BACKUP_KEY] ?: false
}
suspend fun setOnboardingBackupDone(done: Boolean = true) {
store.edit { it[ONBOARDING_BACKUP_KEY] = done }
}
/** Whether the wizard's default-view step has been answered (or skipped). */
val onboardingViewDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_VIEW_KEY] ?: false
}
suspend fun setOnboardingViewDone(done: Boolean = true) {
store.edit { it[ONBOARDING_VIEW_KEY] = done }
}
/** Whether the wizard's month-style step has been answered (or skipped). */
val onboardingMonthStyleDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_MONTH_STYLE_KEY] ?: false
}
suspend fun setOnboardingMonthStyleDone(done: Boolean = true) {
store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done }
}
/**
* Whether the wizard's calendar-visibility notice has been read. Separate
* from [CalendarPrefs.visibilityNoticePending], which says whether this
* install has anything to announce at all.
*/
val onboardingVisibilityDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_VISIBILITY_KEY] ?: false
}
suspend fun setOnboardingVisibilityDone(done: Boolean = true) {
store.edit { it[ONBOARDING_VISIBILITY_KEY] = done }
}
/** Whether the wizard's closing screen has been seen. */
val onboardingDoneShown: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_DONE_KEY] ?: false
}
suspend fun setOnboardingDoneShown(shown: Boolean = true) {
store.edit { it[ONBOARDING_DONE_KEY] = shown }
}
/**
* Close the wizard for good: the closing screen has been seen and the
* install stops counting as fresh, so a later revoke-and-re-grant is only
* the permission screen. One edit, so the plan can't see it half-closed.
*/
suspend fun finishOnboardingWizard() {
store.edit { prefs ->
prefs[ONBOARDING_DONE_KEY] = true
prefs[ONBOARDING_WIZARD_ARMED_KEY] = false
}
} }
/** /**
@@ -958,7 +866,6 @@ class SettingsPrefs @Inject constructor(
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style") internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale") internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar") internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
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")
@@ -975,14 +882,6 @@ class SettingsPrefs @Inject constructor(
internal const val MAX_EVENT_DURATION = 1_440 internal const val MAX_EVENT_DURATION = 1_440
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done") internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
internal val ONBOARDING_MONTH_STYLE_KEY =
booleanPreferencesKey("onboarding_month_style_done")
internal val ONBOARDING_VISIBILITY_KEY =
booleanPreferencesKey("onboarding_visibility_done")
internal val ONBOARDING_DONE_KEY = booleanPreferencesKey("onboarding_done_shown")
internal val ALLOW_COLOR_UNSUPPORTED_KEY = internal val ALLOW_COLOR_UNSUPPORTED_KEY =
booleanPreferencesKey("allow_color_unsupported_calendars") booleanPreferencesKey("allow_color_unsupported_calendars")
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes") internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")

View File

@@ -44,16 +44,9 @@ class ProviderReminderInstanceSource @Inject constructor(
// `visible` is the flag the app's one visibility model writes (#75). // `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means // The status clause mirrors CalendarDataSource.instances: NULL means
// "normal", so a bare `!= CANCELED` would drop every ordinary event. // "normal", so a bare `!= CANCELED` would drop every ordinary event.
// An invitation the user declined is answered — it stays on the calendar
// struck through, but it plans nothing (#180). NULL again means "no
// answer recorded", which is not a "no".
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " + val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
"(${CalendarContract.Instances.STATUS} IS NULL OR " + "(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != " + "${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
"${CalendarContract.Events.STATUS_CANCELED}) AND " +
"(${CalendarContract.Instances.SELF_ATTENDEE_STATUS} IS NULL OR " +
"${CalendarContract.Instances.SELF_ATTENDEE_STATUS} != " +
"${CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED})"
return context.contentResolver.query( return context.contentResolver.query(
uri, OCCURRENCE_PROJECTION, selection, null, null, uri, OCCURRENCE_PROJECTION, selection, null, null,
)?.use { c -> )?.use { c ->

View File

@@ -7,7 +7,6 @@ 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.data.calendar.CalendarDataSource import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -91,8 +90,7 @@ class ReminderScanner @Inject constructor(
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead) val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
val planned = planReminders( val planned = planReminders(
instances = occurrences, instances = occurrences,
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }) minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
.withoutProviderDefaults(),
zone = ZoneId.systemDefault(), zone = ZoneId.systemDefault(),
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(), allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
) )

View File

@@ -110,17 +110,3 @@ private const val CURATION_TRIGGER_SIZE = 36
* apart that two swatches never read as the same colour in the grid. * apart that two swatches never read as the same colour in the grid.
*/ */
private const val MIN_DISTANCE = 0.025f private const val MIN_DISTANCE = 0.025f
/**
* The palette entry closest to [argb], or null when the account publishes no
* palette (its calendars then take a raw `EVENT_COLOR` instead).
*
* Used to land a colour that came from outside the account — an imported `.ics`
* carries an arbitrary ARGB, while a palette calendar only accepts one of its
* own keys. Distance is measured in Oklab, so the match is the one that looks
* closest rather than the one that is closest in RGB coordinates.
*/
fun List<EventColorOption>.nearestTo(argb: Int): EventColorOption? {
val target = oklchOf(argb)
return minByOrNull { oklchOf(it.argb).distanceTo(target) }
}

View File

@@ -1,256 +0,0 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant
/** An event with the free text the search may have matched it on. */
data class SearchCandidate(
val event: EventInstance,
val description: String? = null,
/** Null when the event has none: [EventInstance.title] is then a display
* placeholder, which the query must not catch on. */
val title: String? = event.title,
)
/** A matched run inside one of a hit's texts, for highlighting. */
data class MatchSpan(val start: Int, val end: Int)
/** The part of a description a token matched, elided to what fits a row. */
data class DescriptionSnippet(
val text: String,
val spans: List<MatchSpan>,
)
/** One result: the event, plus where the query matched it. */
data class SearchHit(
val event: EventInstance,
val titleSpans: List<MatchSpan> = emptyList(),
val locationSpans: List<MatchSpan> = emptyList(),
val descriptionSnippet: DescriptionSnippet? = null,
/** Already over, so the row can say so. Finished-today counts. */
val isPast: Boolean = false,
)
/** One month's worth of hits, as the results list draws them under a header. */
data class SearchMonth(
val year: Int,
/** 112, so the UI can build the month's first day for a localized label. */
val monthNumber: Int,
val hits: List<SearchHit>,
)
/** A finished search: a calendar of hits, plus the description-only ones. */
data class SearchResults(
val months: List<SearchMonth>,
val inDescriptions: List<SearchHit>,
) {
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
/** Every hit, whichever section it sits in — for select-all and delete. */
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
}
/**
* Turning a typed query into the results list.
*
* An event has to carry *every* whitespace-separated token, each in any of
* title / location / description; case is folded in Kotlin rather than by SQL's
* ASCII-only `LIKE`, so "ärzte" finds "Ärzte".
*
* Results are a calendar, not a ranking: months from the current one, then
* forwards, then backwards, and inside a month what is still to come before
* what has passed. The day is the boundary, not the moment. A hit the query
* only reached through its description is held back in
* [SearchResults.inDescriptions] rather than dated among the real ones.
*/
object EventSearch {
/** Characters of description kept around the match in a row's snippet. */
private const val SNIPPET_LENGTH = 96
/** Characters of lead-in shown before the match, when there is room. */
private const val SNIPPET_LEAD = 24
private val WHITESPACE = Regex("\\s+")
/** Shared with the data layer so its SQL pre-filter tokenises identically. */
fun tokenize(query: String): List<String> =
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
/**
* Search [candidates] for [query], dropping any that don't carry every token.
* [now] marks a hit as over, [todayStart] is where it stops being current.
* [zone] places each event on the calendar through the span rule, so an
* all-day event keeps its own day whichever side of UTC we are on (#82).
*/
fun search(
candidates: List<SearchCandidate>,
query: String,
now: Instant,
todayStart: Instant,
zone: TimeZone,
): SearchResults {
val tokens = tokenize(query)
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
val today = todayStart.toLocalDateTime(zone).date
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
.thenComparator { a, b ->
// What is left reads forwards; what is behind us reads backwards
// from today, nearest first.
if (upcoming(a)) {
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
} else {
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
}
}
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
return SearchResults(
months = groupByMonth(dated, zone, todayStart),
inDescriptions = inDescriptions.map { it.hit },
)
}
/** Months from the current one, then forwards, then backwards. */
private fun groupByMonth(
dated: List<Matched>,
zone: TimeZone,
todayStart: Instant,
): List<SearchMonth> {
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
return dated
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
.entries
.sortedWith(
compareBy<Map.Entry<Int, List<Matched>>> {
when {
it.key == currentKey -> 0
it.key > currentKey -> 1
else -> 2
}
}.thenComparator { a, b ->
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
},
)
.map { (key, entries) ->
SearchMonth(
year = key / 12,
monthNumber = key % 12 + 1,
hits = entries.map { it.hit },
)
}
}
/** Year and month as one comparable number. */
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
private fun match(
candidate: SearchCandidate,
tokens: List<String>,
now: Instant,
zone: TimeZone,
): Matched? {
val title = candidate.title?.takeIf { it.isNotBlank() }
val location = candidate.event.location?.takeIf { it.isNotBlank() }
// Collapsed so a multi-line description matches the way it is drawn.
val description = candidate.description
?.replace(WHITESPACE, " ")
?.trim()
?.takeIf { it.isNotEmpty() }
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
val present = { token: String ->
title?.contains(token, ignoreCase = true) == true ||
location?.contains(token, ignoreCase = true) == true ||
description?.contains(token, ignoreCase = true) == true
}
if (!tokens.all(present)) return null
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty()
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
return Matched(
hit = SearchHit(
event = candidate.event,
titleSpans = titleSpans,
locationSpans = locationSpans,
descriptionSnippet = description
?.takeIf { descriptionSpans.isNotEmpty() }
?.let { snippet(it, descriptionSpans) },
isPast = candidate.event.dayEnd(zone) < now,
),
// Nothing in the name or the place caught the query: it is only here
// for something in its notes.
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
)
}
/** Every occurrence of every token in [text], merged where they overlap. */
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
val raw = mutableListOf<MatchSpan>()
for (token in tokens) {
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
while (index >= 0) {
raw += MatchSpan(index, index + token.length)
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
}
}
if (raw.isEmpty()) return emptyList()
val sorted = raw.sortedBy { it.start }
val merged = mutableListOf(sorted.first())
for (span in sorted.drop(1)) {
val last = merged.last()
if (span.start <= last.end) {
merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end))
} else {
merged += span
}
}
return merged
}
/**
* A window of [description] around its first match, elided at both ends,
* with the spans rebased onto it and any falling outside dropped.
*/
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
if (description.length <= SNIPPET_LENGTH) {
return DescriptionSnippet(description, spans)
}
val first = spans.first().start
val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH))
val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length)
val prefix = if (start > 0) "" else ""
val suffix = if (end < description.length) "" else ""
val text = prefix + description.substring(start, end) + suffix
val shift = prefix.length - start
val rebased = spans
.filter { it.start >= start && it.end <= end }
.map { MatchSpan(it.start + shift, it.end + shift) }
return DescriptionSnippet(text, rebased)
}
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
val event: EventInstance get() = hit.event
}
/**
* The event's first day as an instant in [zone]. An all-day event sits at
* UTC midnight, so its raw start would rank it on a day it isn't drawn on.
*/
private fun EventInstance.dayStart(zone: TimeZone): Instant =
if (isAllDay) spanFirstDay(zone).atStartOfDayIn(zone) else start
/** The moment the event's last day is over in [zone], exclusive. */
private fun EventInstance.dayEnd(zone: TimeZone): Instant =
if (isAllDay) spanLastDay(zone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) else end
}

View File

@@ -63,17 +63,11 @@ data class EventInstance(
val color: Int, val color: Int,
val location: String?, val location: String?,
/** /**
* Only search results, which read the series master, fill this in — the * Whether this row's event carries a recurrence rule. Only search results
* Instances query already yields one row per occurrence. * (which read the series master) fill this in — the Instances query already
* yields one row per occurrence, so it stays false there.
*/ */
val isRecurring: Boolean = false, val isRecurring: Boolean = false,
/**
* This device user answered "no" to the invitation
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
* still an appointment someone expects an answer about — but every surface
* strikes it through, and it plans no reminders (#180).
*/
val isDeclined: Boolean = false,
) )
/** /**

View File

@@ -1,80 +0,0 @@
package de.jeanlucmakiola.calendula.domain.ics
/** Opaque alpha, forced onto any colour that arrives without one. */
private const val OPAQUE_ALPHA = 0xFF000000.toInt()
/**
* Resolve a colour value out of a foreign `.ics` to an opaque ARGB int.
*
* Three encodings are in the wild and all appear in the files Calendula is
* asked to import:
* - a CSS3 colour name, which is what RFC 7986 `COLOR` is defined as
* (`COLOR:tomato`);
* - a raw signed Android colour int, which the Simple Calendar / Fossify
* family writes in its `X-…-COLOR` extensions (`X-FOSSIFY-EVENT-COLOR:-2818048`);
* - `#rrggbb` / `#aarrggbb` hex, used by assorted other producers.
*
* Anything else — including the literal `null` Fossify emits when its calendar
* lookup misses — resolves to null.
*/
fun parseIcsColorValue(raw: String?): Int? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
if (value.startsWith("#")) {
val hex = value.substring(1)
val parsed = hex.toLongOrNull(16)?.toInt() ?: return null
return when (hex.length) {
6 -> parsed or OPAQUE_ALPHA
8 -> parsed
else -> null
}
}
value.toIntOrNull()?.let { return if (it ushr 24 == 0) it or OPAQUE_ALPHA else it }
return cssColors[value.lowercase()]
}
/** The CSS3 extended colour keywords, the value space of RFC 7986 `COLOR`. */
private val cssColors: Map<String, Int> by lazy {
CSS3_TABLE.split(' ').associate { entry ->
val name = entry.substringBefore('=')
name to (entry.substringAfter('=').toInt(16) or OPAQUE_ALPHA)
}
}
private const val CSS3_TABLE =
"aliceblue=f0f8ff antiquewhite=faebd7 aqua=00ffff aquamarine=7fffd4 " +
"azure=f0ffff beige=f5f5dc bisque=ffe4c4 black=000000 blanchedalmond=ffebcd " +
"blue=0000ff blueviolet=8a2be2 brown=a52a2a burlywood=deb887 cadetblue=5f9ea0 " +
"chartreuse=7fff00 chocolate=d2691e coral=ff7f50 cornflowerblue=6495ed " +
"cornsilk=fff8dc crimson=dc143c cyan=00ffff darkblue=00008b darkcyan=008b8b " +
"darkgoldenrod=b8860b darkgray=a9a9a9 darkgreen=006400 darkgrey=a9a9a9 " +
"darkkhaki=bdb76b darkmagenta=8b008b darkolivegreen=556b2f darkorange=ff8c00 " +
"darkorchid=9932cc darkred=8b0000 darksalmon=e9967a darkseagreen=8fbc8f " +
"darkslateblue=483d8b darkslategray=2f4f4f darkslategrey=2f4f4f darkturquoise=00ced1 " +
"darkviolet=9400d3 deeppink=ff1493 deepskyblue=00bfff dimgray=696969 " +
"dimgrey=696969 dodgerblue=1e90ff firebrick=b22222 floralwhite=fffaf0 " +
"forestgreen=228b22 fuchsia=ff00ff gainsboro=dcdcdc ghostwhite=f8f8ff " +
"gold=ffd700 goldenrod=daa520 gray=808080 green=008000 greenyellow=adff2f " +
"grey=808080 honeydew=f0fff0 hotpink=ff69b4 indianred=cd5c5c indigo=4b0082 " +
"ivory=fffff0 khaki=f0e68c lavender=e6e6fa lavenderblush=fff0f5 lawngreen=7cfc00 " +
"lemonchiffon=fffacd lightblue=add8e6 lightcoral=f08080 lightcyan=e0ffff " +
"lightgoldenrodyellow=fafad2 lightgray=d3d3d3 lightgreen=90ee90 lightgrey=d3d3d3 " +
"lightpink=ffb6c1 lightsalmon=ffa07a lightseagreen=20b2aa lightskyblue=87cefa " +
"lightslategray=778899 lightslategrey=778899 lightsteelblue=b0c4de " +
"lightyellow=ffffe0 lime=00ff00 limegreen=32cd32 linen=faf0e6 magenta=ff00ff " +
"maroon=800000 mediumaquamarine=66cdaa mediumblue=0000cd mediumorchid=ba55d3 " +
"mediumpurple=9370db mediumseagreen=3cb371 mediumslateblue=7b68ee " +
"mediumspringgreen=00fa9a mediumturquoise=48d1cc mediumvioletred=c71585 " +
"midnightblue=191970 mintcream=f5fffa mistyrose=ffe4e1 moccasin=ffe4b5 " +
"navajowhite=ffdead navy=000080 oldlace=fdf5e6 olive=808000 olivedrab=6b8e23 " +
"orange=ffa500 orangered=ff4500 orchid=da70d6 palegoldenrod=eee8aa " +
"palegreen=98fb98 paleturquoise=afeeee palevioletred=db7093 papayawhip=ffefd5 " +
"peachpuff=ffdab9 peru=cd853f pink=ffc0cb plum=dda0dd powderblue=b0e0e6 " +
"purple=800080 rebeccapurple=663399 red=ff0000 rosybrown=bc8f8f royalblue=4169e1 " +
"saddlebrown=8b4513 salmon=fa8072 sandybrown=f4a460 seagreen=2e8b57 " +
"seashell=fff5ee sienna=a0522d silver=c0c0c0 skyblue=87ceeb slateblue=6a5acd " +
"slategray=708090 slategrey=708090 snow=fffafa springgreen=00ff7f " +
"steelblue=4682b4 tan=d2b48c teal=008080 thistle=d8bfd8 tomato=ff6347 " +
"turquoise=40e0d0 violet=ee82ee wheat=f5deb3 white=ffffff whitesmoke=f5f5f5 " +
"yellow=ffff00 yellowgreen=9acd32"

View File

@@ -22,12 +22,6 @@ data class IcsEvent(
val zoneId: String, val zoneId: String,
/** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */ /** Bare RRULE value (no `RRULE:` prefix), or null for a one-off event. */
val recurrenceRule: String? = null, val recurrenceRule: String? = null,
/**
* Occurrences deleted from the series, in the provider's `EXDATE` shape —
* `yyyyMMdd` for an all-day series, a UTC `yyyyMMddTHHmmssZ` stamp
* otherwise. Empty for a one-off event.
*/
val exDates: List<String> = emptyList(),
val location: String? = null, val location: String? = null,
val description: String? = null, val description: String? = null,
/** Reminder lead times in minutes before start (raw provider offsets). */ /** Reminder lead times in minutes before start (raw provider offsets). */

View File

@@ -7,25 +7,14 @@ import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.number
import kotlinx.datetime.toInstant import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.math.ceil
import kotlin.time.Instant import kotlin.time.Instant
/** Milliseconds in a calendar day; all-day times are UTC midnights, so this is exact. */
private const val DAY_MILLIS = 86_400_000L
private const val MINUTES_PER_DAY = 1_440
/** /**
* A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent], * A `VEVENT` parsed from an `.ics` file — the read-side mirror of [IcsEvent],
* but [uid] is nullable (an incoming event may carry none; the insert layer * but [uid] is nullable (an incoming event may carry none; the insert layer
* then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror * then assigns one). Times are absolute instants; [isAllDay]/[zoneId] mirror
* how the writer encoded them. * how the writer encoded them.
*
* [reminderMinutes] holds raw lead times as the file's `VALARM`s expressed
* them — minutes *before* start, so a negative entry means "after start".
* [semanticReminderMinutes] turns those into the lead times Calendula models.
*/ */
data class ParsedIcsEvent( data class ParsedIcsEvent(
val uid: String?, val uid: String?,
@@ -41,45 +30,8 @@ data class ParsedIcsEvent(
val status: EventStatus = EventStatus.Confirmed, val status: EventStatus = EventStatus.Confirmed,
val availability: Availability = Availability.Busy, val availability: Availability = Availability.Busy,
val calendarName: String? = null, val calendarName: String? = null,
/** Opaque ARGB the source file gave this event, if any (see [parseIcsColorValue]). */
val color: Int? = null,
/** Excluded occurrences, already in the provider's `EXDATE` shape. */
val exDates: List<String> = emptyList(),
/** True when this came from a `VTODO` rather than a `VEVENT`. */
val isTask: Boolean = false,
) )
/**
* [reminderMinutes] mapped onto Calendula's model: a lead time in minutes before
* the event, never negative.
*
* For a timed event the raw lead time already is that, and a trigger that fires
* *after* the start models no lead time at all — it is dropped rather than
* clamped, which would invent a reminder at the start the file never asked for
* (a legal `TRIGGER:PT30M` follow-up alarm is the case). All-day reminders are
* whole days before the event, fired at the user's configured time of day (see
* `AllDayReminderEncoding`), so a raw offset counts the whole days its trigger
* lands *earlier than* the event's UTC midnight — i.e. it rounds **up**.
*
* Rounding to the nearest day instead would lose a day for every producer whose
* all-day notifications fire after noon: a file's raw offset is
* `days × 1440 timeOfDay`, so "1 day before at 18:00" arrives as
* `TRIGGER:-PT6H` (raw 360) and would read as "on the day". Rounding up also
* lands Fossify's encoding correctly — it writes "on the day at 09:00" as a
* *positive* `TRIGGER:P0DT9H0M0S`, nine hours after the UTC midnight, which
* ceils to zero days before.
*/
fun ParsedIcsEvent.semanticReminderMinutes(): List<Int> = reminderMinutes
.mapNotNull { raw ->
if (isAllDay) {
ceil(raw.toDouble() / MINUTES_PER_DAY).toInt().coerceAtLeast(0) * MINUTES_PER_DAY
} else {
raw.takeIf { it >= 0 }
}
}
.distinct()
.sorted()
/** Things the parser dropped rather than failing — surfaced in the import report. */ /** Things the parser dropped rather than failing — surfaced in the import report. */
enum class IcsParseWarning { enum class IcsParseWarning {
/** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */ /** A `RECURRENCE-ID` override occurrence (not modelled; only masters import). */
@@ -93,12 +45,6 @@ enum class IcsParseWarning {
/** A `TZID` couldn't be resolved against the device tz database (used local zone). */ /** A `TZID` couldn't be resolved against the device tz database (used local zone). */
UnknownTimezone, UnknownTimezone,
/** `VTODO` components were imported as events — Calendula has no task model. */
TasksImportedAsEvents,
/** A malformed `RRULE` was repaired or dropped (see [sanitizeRrule]). */
RecurrenceRuleRepaired,
} }
data class IcsParseResult( data class IcsParseResult(
@@ -106,28 +52,8 @@ data class IcsParseResult(
val warnings: Set<IcsParseWarning>, val warnings: Set<IcsParseWarning>,
) )
/** /** Outcome of a bulk `.ics` import into one calendar. */
* Outcome of a bulk `.ics` import into one calendar. [failed] counts events the data class IcsImportSummary(val imported: Int, val skippedDuplicate: Int)
* provider rejected — the import continues past them, so a single unusable
* event can't cost the user the rest of the file.
*
* [notAttempted] counts what a retry still has to import, because the permission
* or the target calendar went away mid-file and every remaining insert would have
* failed the same way. Events past that point whose UID is already in the
* calendar count under [skippedDuplicate] instead — they were never going to be
* added either way. It travels in the summary rather than as an exception so the
* counts of what *did* land survive: an import is only idempotent for events that
* carry a UID, so a retry told "nothing happened" would duplicate the rest.
*/
data class IcsImportSummary(
val imported: Int,
val skippedDuplicate: Int,
val failed: Int = 0,
val notAttempted: Int = 0,
)
/** The non-standard property most exporters name the whole calendar with. */
private const val CALENDAR_NAME_PROPERTY = "X-WR-CALNAME"
/** /**
* Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and * Hand-rolled RFC 5545 reader, the inverse of [IcsWriter]. Pure and
@@ -136,9 +62,6 @@ private const val CALENDAR_NAME_PROPERTY = "X-WR-CALNAME"
* (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings] * (`RECURRENCE-ID`, attendees, unresolved `TZID`) are reported as [warnings]
* rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is * rather than silently dropped. `VTIMEZONE` blocks are skipped — a `TZID` is
* resolved against the OS tz database instead ([deviceZone] is the fallback). * resolved against the OS tz database instead ([deviceZone] is the fallback).
*
* `VTODO` components are read as all-day/timed events: Calendula models no
* tasks, and dropping them would silently lose half of some exports.
*/ */
class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) { class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault()) {
@@ -146,57 +69,42 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
val lines = unfoldLines(text) val lines = unfoldLines(text)
val events = mutableListOf<ParsedIcsEvent>() val events = mutableListOf<ParsedIcsEvent>()
val warnings = mutableSetOf<IcsParseWarning>() val warnings = mutableSetOf<IcsParseWarning>()
// Scanned up front, not in document order: an X-WR-CALNAME after the var calendarName: String? = null
// first VEVENT still names the calendar. The cheap prefix test first,
// so a file without one doesn't pay a full parse to find nothing.
val calendarName = lines.asSequence()
.filter { it.startsWith(CALENDAR_NAME_PROPERTY, ignoreCase = true) }
.mapNotNull(::parseContentLine)
.firstOrNull { it.name == CALENDAR_NAME_PROPERTY }
?.let { unescapeText(it.value).trim().ifEmpty { null } }
var i = 0 var i = 0
while (i < lines.size) { while (i < lines.size) {
val line = parseContentLine(lines[i]) val line = parseContentLine(lines[i])
if (line == null) { i++; continue } if (line == null) { i++; continue }
val component = when {
line.isBegin("VEVENT") -> "VEVENT"
line.isBegin("VTODO") -> "VTODO"
else -> null
}
when { when {
component != null -> { line.isBegin("VEVENT") -> {
val end = indexOfEnd(lines, i + 1, component) val end = indexOfEnd(lines, i + 1, "VEVENT")
parseComponent( parseVevent(lines.subList(i + 1, end), calendarName, warnings)
body = lines.subList(i + 1, end), ?.let(events::add)
fileCalendarName = calendarName,
warnings = warnings,
isTask = component == "VTODO",
)?.let(events::add)
i = end + 1 i = end + 1
} }
line.isBegin("VTIMEZONE") -> { line.isBegin("VTIMEZONE") -> {
// Skipped wholesale; TZIDs resolve against the OS tz database. // Skipped wholesale; TZIDs resolve against the OS tz database.
i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1 i = indexOfEnd(lines, i + 1, "VTIMEZONE") + 1
} }
line.name == "X-WR-CALNAME" -> {
calendarName = unescapeText(line.value).trim().ifEmpty { null }
i++
}
else -> i++ else -> i++
} }
} }
if (events.any { it.isTask }) warnings.add(IcsParseWarning.TasksImportedAsEvents)
return IcsParseResult(events, warnings) return IcsParseResult(events, warnings)
} }
private fun parseComponent( private fun parseVevent(
body: List<String>, body: List<String>,
fileCalendarName: String?, fileCalendarName: String?,
warnings: MutableSet<IcsParseWarning>, warnings: MutableSet<IcsParseWarning>,
isTask: Boolean,
): ParsedIcsEvent? { ): ParsedIcsEvent? {
var uid: String? = null var uid: String? = null
var summary = "" var summary = ""
var dtStart: IcsDateTime? = null var dtStart: IcsDateTime? = null
var dtEnd: IcsDateTime? = null var dtEnd: IcsDateTime? = null
var due: IcsDateTime? = null
var duration: String? = null var duration: String? = null
var rrule: String? = null var rrule: String? = null
var location: String? = null var location: String? = null
@@ -204,11 +112,7 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
var status = EventStatus.Confirmed var status = EventStatus.Confirmed
var availability = Availability.Busy var availability = Availability.Busy
var calendarName = fileCalendarName var calendarName = fileCalendarName
var category: String? = null
var eventColor: Int? = null
var calendarColor: Int? = null
val reminders = mutableListOf<Int>() val reminders = mutableListOf<Int>()
val exDateLines = mutableListOf<IcsContentLine>()
var skipAsOverride = false var skipAsOverride = false
var i = 0 var i = 0
@@ -225,11 +129,9 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
"UID" -> uid = line.value.trim().ifEmpty { null } "UID" -> uid = line.value.trim().ifEmpty { null }
"SUMMARY" -> summary = unescapeText(line.value) "SUMMARY" -> summary = unescapeText(line.value)
"DTSTART" -> dtStart = parseIcsDateTime(line, warnings) "DTSTART" -> dtStart = parseIcsDateTime(line, warnings)
"DUE" -> if (isTask) due = parseIcsDateTime(line, warnings)
"DTEND" -> dtEnd = parseIcsDateTime(line, warnings) "DTEND" -> dtEnd = parseIcsDateTime(line, warnings)
"DURATION" -> duration = line.value.trim() "DURATION" -> duration = line.value.trim()
"RRULE" -> rrule = line.value.trim().ifEmpty { null } "RRULE" -> rrule = line.value.trim().ifEmpty { null }
"EXDATE" -> exDateLines.add(line)
"LOCATION" -> location = unescapeText(line.value).ifEmpty { null } "LOCATION" -> location = unescapeText(line.value).ifEmpty { null }
"DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null } "DESCRIPTION" -> description = unescapeText(line.value).ifEmpty { null }
"STATUS" -> status = mapIcsStatus(line.value) "STATUS" -> status = mapIcsStatus(line.value)
@@ -240,19 +142,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
"ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored) "ATTENDEE" -> warnings.add(IcsParseWarning.AttendeesIgnored)
"X-CALENDULA-CALENDAR" -> "X-CALENDULA-CALENDAR" ->
calendarName = unescapeText(line.value).trim().ifEmpty { calendarName } calendarName = unescapeText(line.value).trim().ifEmpty { calendarName }
// Fossify names the source calendar per event; it never writes
// an X-WR-CALNAME, so this is the only calendar label its files
// carry. Elsewhere CATEGORIES is a plain tag list, so it only
// stands in where nothing named the calendar outright.
"CATEGORIES" -> category = singleCategory(line.value) ?: category
// Per-event colour, most specific encoding first (see parseIcsColorValue).
"X-FOSSIFY-EVENT-COLOR", "X-SMT-EVENT-COLOR" ->
eventColor = parseIcsColorValue(line.value) ?: eventColor
"COLOR" -> eventColor = eventColor ?: parseIcsColorValue(line.value)
// The *calendar's* colour. Kept as a fallback so a migration that
// folds several source calendars into one keeps them apart visually.
"X-FOSSIFY-CATEGORY-COLOR", "X-SMT-CATEGORY-COLOR", "CATEGORY_COLOR" ->
calendarColor = parseIcsColorValue(line.value) ?: calendarColor
} }
i++ i++
} }
@@ -261,125 +150,37 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped) warnings.add(IcsParseWarning.ModifiedOccurrenceSkipped)
return null return null
} }
// Most producers give a VTODO only a DUE, which is then the moment the
// task sits at. One that carries a DTSTART too means the span between
// them, so DUE stands in for the end it has no DTEND for. Resolved after
// the sweep rather than inside it: property order isn't guaranteed, and
// a DUE ahead of the DTSTART would otherwise be taken for the start.
if (isTask && due != null) {
if (dtStart == null) dtStart = due else if (dtEnd == null) dtEnd = due
}
val start = dtStart ?: run { val start = dtStart ?: run {
warnings.add(IcsParseWarning.EventWithoutStartSkipped) warnings.add(IcsParseWarning.EventWithoutStartSkipped)
return null return null
} }
val recurrence = sanitizeRrule(rrule) val end = dtEnd
if (recurrence.repaired) warnings.add(IcsParseWarning.RecurrenceRuleRepaired) ?: duration?.let {
val end = resolveEnd(start, dtEnd, duration) start.copy(
instant = Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
),
)
}
?: start
return ParsedIcsEvent( return ParsedIcsEvent(
uid = uid, uid = uid,
summary = summary, summary = summary,
start = start.instant, start = start.instant,
end = end, end = end.instant,
isAllDay = start.isAllDay, isAllDay = start.isAllDay,
zoneId = start.zoneId, zoneId = start.zoneId,
recurrenceRule = recurrence.rule, recurrenceRule = rrule,
location = location, location = location,
description = description, description = description,
reminderMinutes = reminders.distinct(), reminderMinutes = reminders.distinct(),
status = status, status = status,
availability = availability, availability = availability,
calendarName = calendarName ?: category, calendarName = calendarName,
color = eventColor ?: calendarColor,
exDates = if (recurrence.rule == null) {
emptyList()
} else {
normalizeExDates(exDateLines, start)
},
isTask = isTask,
) )
} }
/** /** A VALARM's lead time in minutes before start, or null if not a usable relative trigger. */
* The event's end instant.
*
* All-day events get two RFC 5545 §3.6.1 rules applied that a literal read
* would miss: a `DATE`-valued `DTSTART` with no `DTEND` lasts one day, and —
* since the provider expands a zero-length all-day series into no instances
* at all — an all-day event may never end at or before it starts.
*/
private fun resolveEnd(
start: IcsDateTime,
dtEnd: IcsDateTime?,
duration: String?,
): Instant {
val explicit = dtEnd?.instant ?: duration?.let {
Instant.fromEpochMilliseconds(
start.instant.toEpochMilliseconds() + parseRfc2445DurationMillis(it),
)
}
if (!start.isAllDay) return explicit ?: start.instant
return explicit?.takeIf { it > start.instant } ?: start.instant.plusDays(1)
}
/**
* `EXDATE` values in the shape `Events.EXDATE` wants: `yyyyMMdd` for an
* all-day series, a UTC `yyyyMMddTHHmmssZ` stamp otherwise.
*
* Fossify stores its excluded occurrences as bare day codes and writes them
* out that way even for timed series, where the property is then ambiguous.
* Such a value is resolved against the series' own time of day, which is
* what it meant — its repetitions never move within the day.
*
* An all-day exclusion names a calendar day, so a zone-qualified DATE-TIME
* still means the day it spells out; resolving it to an instant first and
* reading *that* back in UTC would roll a midnight east of UTC onto the day
* before and exclude an occurrence that doesn't exist.
*/
private fun normalizeExDates(lines: List<IcsContentLine>, start: IcsDateTime): List<String> {
if (lines.isEmpty()) return emptyList()
val startZone = runCatching { TimeZone.of(start.zoneId) }.getOrNull() ?: deviceZone
val startTime = start.instant.toLocalDateTime(startZone).time
return lines
.flatMap { line -> line.value.split(',').map { line to it.trim() } }
.mapNotNull { (line, token) ->
when {
token.isEmpty() -> null
start.isAllDay -> parseBasicDate(token.substringBefore('T'))?.let(::dayCode)
token.contains('T') -> parseExDateTime(token, line, startZone)?.let(::utcStamp)
else -> parseBasicDate(token)
?.let { utcStamp(LocalDateTime(it, startTime).toInstant(startZone)) }
}
}
.distinct()
}
/**
* [startZone] is the series' own zone, which a floating value (no `Z`, no
* `TZID`) has to be read in — RFC 5545 ties `EXDATE` to `DTSTART`'s form,
* and reading it in the device's zone instead would put the exclusion on an
* instant no occurrence has.
*/
private fun parseExDateTime(
token: String,
line: IcsContentLine,
startZone: TimeZone,
): Instant? {
val ldt = parseBasicDateTime(token.removeSuffix("Z")) ?: return null
val zone = when {
token.endsWith("Z") -> TimeZone.UTC
else -> line.params["TZID"]?.let { runCatching { TimeZone.of(it) }.getOrNull() }
?: startZone
}
return ldt.toInstant(zone)
}
/**
* A `VALARM`'s offset from the event start, in minutes *before* it — so a
* trigger that fires after the start (which is how the Fossify family
* encodes "on the day at 09:00") comes back negative. Null when the trigger
* isn't a usable relative offset.
*/
private fun parseAlarmMinutes(body: List<String>): Int? { private fun parseAlarmMinutes(body: List<String>): Int? {
val trigger = body.asSequence() val trigger = body.asSequence()
.mapNotNull { parseContentLine(it) } .mapNotNull { parseContentLine(it) }
@@ -388,7 +189,8 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
// Absolute (DATE-TIME) triggers can't be expressed as a lead time. // Absolute (DATE-TIME) triggers can't be expressed as a lead time.
if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null if (trigger.params["VALUE"].equals("DATE-TIME", true)) return null
val millis = parseRfc2445DurationMillis(trigger.value) val millis = parseRfc2445DurationMillis(trigger.value)
return (-millis / 60_000L).toInt() // Negative = before start (the normal case) → positive lead minutes.
return (-millis / 60_000L).toInt().coerceAtLeast(0)
} }
private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? { private fun parseIcsDateTime(line: IcsContentLine, warnings: MutableSet<IcsParseWarning>): IcsDateTime? {
@@ -416,34 +218,6 @@ class IcsParser(private val deviceZone: TimeZone = TimeZone.currentSystemDefault
fun IcsContentLine.isBegin(component: String) = fun IcsContentLine.isBegin(component: String) =
name == "BEGIN" && value.trim().equals(component, true) name == "BEGIN" && value.trim().equals(component, true)
fun Instant.plusDays(days: Int): Instant =
Instant.fromEpochMilliseconds(toEpochMilliseconds() + days * DAY_MILLIS)
fun dayCode(date: LocalDate): String =
"%04d%02d%02d".format(date.year, date.month.number, date.day)
fun utcStamp(instant: Instant): String = with(instant.toLocalDateTime(TimeZone.UTC)) {
"%04d%02d%02dT%02d%02d%02dZ".format(year, month.number, day, hour, minute, second)
}
/**
* A single-valued `CATEGORIES`, which is what the Fossify family writes
* for the source calendar's name. A multi-item list is a tag list and
* names no calendar, so it is ignored.
*/
fun singleCategory(raw: String): String? {
var escaped = false
for (c in raw) {
when {
escaped -> escaped = false
c == '\\' -> escaped = true
c == ',' -> return null
}
}
return unescapeText(raw).trim()
.takeIf { it.isNotEmpty() && !it.equals("null", true) }
}
/** Index of the matching `END:<component>` at/after [from], or list end. */ /** Index of the matching `END:<component>` at/after [from], or list end. */
fun indexOfEnd(lines: List<String>, from: Int, component: String): Int { fun indexOfEnd(lines: List<String>, from: Int, component: String): Int {
var i = from var i = from

View File

@@ -1,132 +0,0 @@
package de.jeanlucmakiola.calendula.domain.ics
private val VALID_FREQ = setOf(
"SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY",
)
private val VALID_WEEKDAYS = setOf("SU", "MO", "TU", "WE", "TH", "FR", "SA")
/**
* What `EventRecurrence` accepts in each numeric list part: a value range, plus
* whether `0` is legal. Its `parseNumberList` throws on a non-number, on an item
* outside the range *and* on a zero in the by-position parts, so checking only
* that an item is a number would still hand the provider a rule it rejects.
*
* `BYSETPOS` is the one part it leaves unbounded; `0` is dropped from it anyway
* because RFC 5545 §3.3.10 gives it no meaning.
*/
private val NUMERIC_LIST_PARTS = mapOf(
"BYSECOND" to NumericPart(0..59, zeroAllowed = true),
"BYMINUTE" to NumericPart(0..59, zeroAllowed = true),
"BYHOUR" to NumericPart(0..23, zeroAllowed = true),
"BYMONTHDAY" to NumericPart(-31..31, zeroAllowed = false),
"BYYEARDAY" to NumericPart(-366..366, zeroAllowed = false),
"BYWEEKNO" to NumericPart(-53..53, zeroAllowed = false),
"BYMONTH" to NumericPart(1..12, zeroAllowed = false),
"BYSETPOS" to NumericPart(Int.MIN_VALUE..Int.MAX_VALUE, zeroAllowed = false),
)
private class NumericPart(private val range: IntRange, private val zeroAllowed: Boolean) {
fun accepts(item: String): Boolean {
val n = item.toIntOrNull() ?: return false
return n in range && (zeroAllowed || n != 0)
}
}
/** `UNTIL` in RFC 5545 basic format; anything else fails at expansion time. */
private val UNTIL_SHAPE = Regex("""\d{8}(T\d{6}Z?)?""")
/**
* A foreign RRULE made safe to hand `CalendarContract`.
*
* [rule] is what survived, or null when there is nothing left to salvage.
* [repaired] is true only when a part was actually dropped — pure normalisation
* (case, a stray separator) doesn't count, so a conformant file is never
* reported to the user as faulty.
*/
data class SanitizedRrule(val rule: String?, val repaired: Boolean)
/**
* Salvage a foreign RRULE.
*
* The provider validates every RRULE through `EventRecurrence.parse`, which
* **throws** on a malformed rule — and the throw surfaces from `insert`, not
* from a later read. One bad rule in an imported file would therefore abort the
* write it rides on. Producers do emit such rules: Fossify's exporter writes a
* bare `;BYDAY=` whenever a weekly event carries no weekday mask
* (`Parser.getByDay` interpolates an empty day string unconditionally), which is
* the state every weekly event it imported from elsewhere is left in.
*
* That parser has a fixed table of parts and throws on any name outside it, so
* this is a whitelist: an unrecognised part (RFC 7529 `RSCALE`, a vendor `X-`
* extension) and an unusable value are dropped rather than passed on to fail the
* whole event. Only a missing or unrecognised `FREQ` — without which there is no
* rule at all — gives up entirely.
*/
fun sanitizeRrule(raw: String?): SanitizedRrule {
val rule = raw?.trim()?.removePrefix("RRULE:")?.trim().orEmpty()
if (rule.isEmpty()) return SanitizedRrule(rule = null, repaired = false)
var freqSeen = false
var repaired = false
val parts = mutableListOf<String>()
/** The items [valid] accepts, uppercased and comma-joined; null if none survive. */
fun keepItems(value: String, valid: (String) -> Boolean): String? {
val items = value.split(',').map { it.trim().uppercase() }
val kept = items.filter(valid)
if (kept.size != items.size) repaired = true
return kept.takeIf { it.isNotEmpty() }?.joinToString(",")
}
for (part in rule.split(';')) {
// A stray separator ("FREQ=DAILY;") normalises away; nothing was lost.
// A part that carries text but no '=' ("FREQ=WEEKLY;BYDAY") is a part
// being dropped, so it falls through and marks the rule repaired.
if (part.isBlank()) continue
val key = part.substringBefore('=', "").trim().uppercase()
val value = if ('=' in part) part.substringAfter('=').trim() else ""
val kept = when {
key.isEmpty() || value.isEmpty() -> null
key == "FREQ" ->
value.uppercase().takeIf { it in VALID_FREQ }
?.also { freqSeen = true }
?.let { "FREQ=$it" }
// A non-positive or non-numeric INTERVAL/COUNT is rejected by the
// provider, and a COUNT of zero would expand to no occurrences.
key == "INTERVAL" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "INTERVAL=$it" }
key == "COUNT" -> value.toIntOrNull()?.takeIf { it > 0 }?.let { "COUNT=$it" }
key == "UNTIL" ->
value.uppercase().takeIf { UNTIL_SHAPE.matches(it) }?.let { "UNTIL=$it" }
key == "WKST" ->
value.uppercase().takeIf { it in VALID_WEEKDAYS }?.let { "WKST=$it" }
key == "BYDAY" -> keepItems(value, ::isWeekdayItem)?.let { "BYDAY=$it" }
key in NUMERIC_LIST_PARTS ->
keepItems(value, NUMERIC_LIST_PARTS.getValue(key)::accepts)?.let { "$key=$it" }
else -> null
}
if (kept == null) repaired = true else parts += kept
}
// `EventRecurrence.parse` ends with *two* global checks, not one: a missing
// FREQ throws, and so does an UNTIL that arrives together with a COUNT. A
// rule carrying both is malformed per RFC 5545 §3.3.10 but real producers
// emit it, and left alone it would throw straight out of `insert` — the very
// failure this function exists to prevent. UNTIL is kept: it bounds the
// series at an absolute date, so a stale COUNT can't over-generate past it.
if (parts.any { it.startsWith("UNTIL=") }) {
if (parts.removeAll { it.startsWith("COUNT=") }) repaired = true
}
return if (freqSeen) {
SanitizedRrule(rule = parts.joinToString(";"), repaired = repaired)
} else {
SanitizedRrule(rule = null, repaired = true)
}
}
/** A `BYDAY` item: a two-letter weekday, optionally preceded by an ordinal. */
private fun isWeekdayItem(item: String): Boolean {
if (item.length < 2) return false
val ordinal = item.dropLast(2)
return item.takeLast(2) in VALID_WEEKDAYS &&
(ordinal.isEmpty() || ordinal.toIntOrNull() != null)
}

View File

@@ -46,7 +46,6 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
appendTimes(event) appendTimes(event)
event.recurrenceRule?.takeIf { it.isNotBlank() } event.recurrenceRule?.takeIf { it.isNotBlank() }
?.let { add("RRULE:${it.removePrefix("RRULE:")}") } ?.let { add("RRULE:${it.removePrefix("RRULE:")}") }
appendExDates(event)
event.location?.takeIf { it.isNotBlank() } event.location?.takeIf { it.isNotBlank() }
?.let { add("LOCATION:${escapeText(it)}") } ?.let { add("LOCATION:${escapeText(it)}") }
event.description?.takeIf { it.isNotBlank() } event.description?.takeIf { it.isNotBlank() }
@@ -84,20 +83,6 @@ class IcsWriter(private val prodId: String = ICS_PROD_ID) {
} }
} }
/**
* The series' deleted occurrences. RFC 5545 ties `EXDATE`'s value type to
* `DTSTART`'s, so an all-day series needs the explicit `VALUE=DATE` — without
* it the bare day codes read as an invalid DATE-TIME. Only written for a
* recurring event: an exclusion names an occurrence, and a one-off has none.
*/
private fun MutableList<String>.appendExDates(event: IcsEvent) {
if (event.recurrenceRule.isNullOrBlank()) return
val stamps = event.exDates.filter { it.isNotBlank() }.distinct()
if (stamps.isEmpty()) return
val prefix = if (event.isAllDay) "EXDATE;VALUE=DATE:" else "EXDATE:"
add(prefix + stamps.joinToString(","))
}
private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) { private fun MutableList<String>.appendAlarm(minutes: Int, summary: String) {
add("BEGIN:VALARM") add("BEGIN:VALARM")
add("ACTION:DISPLAY") add("ACTION:DISPLAY")

View File

@@ -13,11 +13,6 @@ import kotlinx.datetime.toLocalDateTime
* calendar, exactly like a fresh create — the user confirms the target and * calendar, exactly like a fresh create — the user confirms the target and
* reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day * reviews everything before saving. Mirrors `EventDetail.toEditForm`'s all-day
* handling (provider all-day times are UTC midnights with an exclusive end). * handling (provider all-day times are UTC midnights with an exclusive end).
*
* [ParsedIcsEvent.color] is deliberately not carried over: picking the calendar
* is the first thing this form asks for, and that clears any colour (a raw ARGB
* is invalid on a calendar whose account publishes a palette). Bulk import,
* where the target is known up front, does keep it.
*/ */
fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm { fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
val (start, end) = if (isAllDay) { val (start, end) = if (isAllDay) {
@@ -36,7 +31,7 @@ fun ParsedIcsEvent.toEventForm(zone: TimeZone): EventForm {
end = end, end = end,
location = location.orEmpty(), location = location.orEmpty(),
description = description.orEmpty(), description = description.orEmpty(),
reminders = semanticReminderMinutes(), reminders = reminderMinutes.distinct().sorted(),
availability = availability, availability = availability,
rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, rrule = recurrenceRule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
) )

View File

@@ -63,12 +63,6 @@ private const val MINUTES_PER_DAY = 1_440
* for *which day* it means ([allDayLeadDays]) and take the hour from * for *which day* it means ([allDayLeadDays]) and take the hour from
* [allDayTimeMinutes], recomposed against each occurrence's own date in [zone]. * [allDayTimeMinutes], recomposed against each occurrence's own date in [zone].
* Duplicate offsets in [minutesByEvent] collapse. * Duplicate offsets in [minutesByEvent] collapse.
*
* Every offset given is planned. The provider's "use the account default"
* sentinel names a lead time no reader can resolve, and is translated out on the
* way in
* ([de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults]) — taken
* as an offset it would arm an alarm a minute after the event began.
*/ */
fun planReminders( fun planReminders(
instances: List<ReminderEventInstance>, instances: List<ReminderEventInstance>,
@@ -76,9 +70,7 @@ fun planReminders(
zone: ZoneId, zone: ZoneId,
allDayTimeMinutes: Int, allDayTimeMinutes: Int,
): List<PlannedReminder> = instances.flatMap { instance -> ): List<PlannedReminder> = instances.flatMap { instance ->
minutesByEvent[instance.eventId].orEmpty() minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
.distinct()
.map { minutes ->
PlannedReminder( PlannedReminder(
instance = instance, instance = instance,
minutes = minutes, minutes = minutes,

View File

@@ -312,17 +312,12 @@ fun CalendarHost(
heldEditKey = key heldEditKey = key
editKey = key editKey = key
} }
// Off by preference (#173) switches off the gesture, not the scope: blocks val moveScope = remember(movableCalendarIds, reschedule) {
// register no drag, while the edit form and its TalkBack action stay put.
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
val moveScope = remember(movableCalendarIds, reschedule, dragToReschedule) {
EventMoveScope( EventMoveScope(
movableCalendarIds = movableCalendarIds, movableCalendarIds = movableCalendarIds,
dragEnabled = dragToReschedule, move = reschedule::move,
move = { reschedule.move(it) },
inFlight = reschedule.inFlight, inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted, undoStarted = reschedule.undoStarted,
abandoned = reschedule.abandoned,
edit = onEditEvent, edit = onEditEvent,
) )
} }

View File

@@ -53,18 +53,4 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false, initialValue = false,
) )
/**
* Whether events may be dragged to another slot to reschedule them (#68, #173).
* Off until the stored value arrives, even though the preference itself defaults
* on: the wrong guess costs an enabled user a gesture for the few frames a cold
* start takes to read DataStore, but re-arms the one thing a disabled user
* switched off to be rid of.
*/
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
} }

View File

@@ -2,15 +2,12 @@ package de.jeanlucmakiola.calendula.ui
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.animation.fadeOut import androidx.compose.material3.MaterialTheme
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -25,22 +22,13 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** What the root is showing: a wizard step, the app, or neither yet. */
private sealed interface RootTarget {
/** DataStore has not emitted yet — render nothing rather than the wrong screen. */
data object Loading : RootTarget
data object App : RootTarget
data class Step(val step: OnboardingStep) : RootTarget
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun RootScreen( fun RootScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -88,63 +76,33 @@ fun RootScreen(
onDispose { lifecycle.removeObserver(obs) } onDispose { lifecycle.removeObserver(obs) }
} }
val onboarding: OnboardingViewModel = hiltViewModel() // Cross-fade the one-time onboarding gates so granting permission / finishing
val plan by onboarding.plan.collectAsStateWithLifecycle() // onboarding eases into the next screen instead of snapping. A fade carries no
// The grant is reported however it was taken, including via Android's // spatial motion, so it stays appropriate under "remove animations" too.
// app-settings screen (caught by the ON_RESUME above); arming the wizard val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
// hangs off that, not off the in-app callback. Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
LaunchedEffect(hasPermission) { if (granted) {
onboarding.setHasPermission(hasPermission) // Second onboarding gate (v1.4, one-time): reminder notifications.
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted() // Null until DataStore's first emission — render nothing for that
// frame instead of flashing the wrong screen.
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
// One-time explainer for the switch to the device's own calendar
// visibility (#75), armed by the reconciler.
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
// Runs on entry however the permission was granted, including via
// Android's app-settings screen (caught by the ON_RESUME above).
LaunchedEffect(Unit) {
visibilityNotice.reconcile()
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
} }
if (onboardingDone == true && noticePending) {
// Arms the one-time explainer for the switch to the device's own calendar CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
// visibility (#75), which the wizard then carries as a step of its own.
LaunchedEffect(hasPermission) {
if (hasPermission) onboarding.reconcileCalendarVisibility()
} }
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
val current = plan when (done) {
val target: RootTarget = when { true -> CalendarHost(
current == null -> RootTarget.Loading
current.current == null -> RootTarget.App
else -> RootTarget.Step(current.current)
}
// Which way the flow is moving, so a step-back slides back. The app sorts
// after every step, so finishing the wizard reads as one more move forward.
val ordinal = when (target) {
RootTarget.Loading -> Int.MIN_VALUE
RootTarget.App -> Int.MAX_VALUE
is RootTarget.Step -> current?.steps?.indexOf(target.step) ?: 0
}
var lastOrdinal by remember { mutableIntStateOf(ordinal) }
val slideDir = if (ordinal < lastOrdinal) -1 else 1
SideEffect { lastOrdinal = ordinal }
val slideSpec = rememberOnboardingSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
AnimatedContent(
targetState = target,
transitionSpec = {
// Coming off the blank first frame is not a step change.
if (initialState == RootTarget.Loading) {
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
} else {
onboardingSlideTransition(
slideDir = slideDir,
spec = slideSpec,
fadeSpec = fadeSpec,
reduceMotion = reduceMotion,
)
}
},
label = "onboardingGate",
) { shown ->
when (shown) {
RootTarget.Loading -> Unit
RootTarget.App -> CalendarHost(
modifier = modifier, modifier = modifier,
requestedDetailKey = requestedDetailKey, requestedDetailKey = requestedDetailKey,
onDetailKeyConsumed = onDetailKeyConsumed, onDetailKeyConsumed = onDetailKeyConsumed,
@@ -158,17 +116,18 @@ fun RootScreen(
requestedEditKey = requestedEditKey, requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed, onEditKeyConsumed = onEditKeyConsumed,
) )
// Both halves of a transition read their position out of the live false -> ReminderOnboardingScreen(
// plan; the one the outgoing step was drawn with is already gone. onFinished = reminderOnboarding::finish,
is RootTarget.Step -> current?.let { plan -> modifier = modifier,
OnboardingSteps( )
step = shown.step, null -> {}
plan = plan, }
viewModel = onboarding, }
onPermissionGranted = { hasPermission = true }, } else {
PermissionScreen(
onGranted = { hasPermission = true },
modifier = modifier, modifier = modifier,
) )
} }
} }
} }
}

View File

@@ -24,13 +24,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import 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.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors 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.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -131,8 +129,8 @@ internal fun AgendaEventRow(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
GroupedRow( GroupedRow(
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier, modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
title = declinedTitle(title, event.isDeclined), title = title,
summary = AnnotatedString(agendaTimeSummary(event, day, zone)), summary = agendaTimeSummary(event, day, zone),
position = position, position = position,
minHeight = 64.dp, minHeight = 64.dp,
leading = { leading = {

View File

@@ -30,6 +30,7 @@ import androidx.compose.material3.Scaffold
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
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -39,9 +40,9 @@ 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.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
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -50,8 +51,6 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
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.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel 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
@@ -96,6 +95,7 @@ fun AgendaScreen(
val showToday by viewModel.showToday.collectAsStateWithLifecycle() val showToday by viewModel.showToday.collectAsStateWithLifecycle()
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle() val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) } var showRangePicker by remember { mutableStateOf(false) }
@@ -130,16 +130,16 @@ fun AgendaScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
AgendaTopBar( AgendaTopBar(
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
onToday = viewModel::goToToday, onToday = viewModel::goToToday,
scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -159,24 +159,13 @@ fun AgendaScreen(
// One bar at the top: the "showing …" header on the left and the // One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle). // session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s -> successState?.takeIf { it.showRangeBar }?.let { s ->
// end lines the selector up with whatever ends the top bar:
// the view switcher's background, or — once #150 hides it —
// the search icon's glyph.
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
AppBarSpacing.Inset
} else {
AppBarSpacing.IconTrailingInset
}
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( .padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) { ) {
AgendaRangeBanner( AgendaRangeBanner(
range = s.range, range = s.range,
@@ -256,9 +245,6 @@ private fun AgendaRangePill(
} }
} }
/** Optical start inset for the range bar's text, set against the title above it. */
private val RANGE_BAR_TEXT_INSET = 28.dp
/** /**
* A header naming the concrete window currently shown under a "showing …" label, * A header naming the concrete window currently shown under a "showing …" label,
* e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name * e.g. "27 Jun 2026" / "27 Jun 3 Jul 2026" / "June 2026". The range's name
@@ -290,7 +276,7 @@ private fun AgendaRangeBanner(
} }
@Composable @Composable
internal fun AgendaContent( private fun AgendaContent(
state: AgendaUiState, state: AgendaUiState,
pastDisplay: PastEventDisplay, pastDisplay: PastEventDisplay,
showToday: Boolean, showToday: Boolean,
@@ -423,22 +409,17 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
private fun AgendaTopBar( private fun AgendaTopBar(
selectedView: CalendarView, selectedView: CalendarView,
onCycleView: () -> Unit, onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
showTodayButton: Boolean, showTodayButton: Boolean,
onToday: () -> Unit, onToday: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) { ) {
TopAppBar( TopAppBar(
title = { title = {
// A plain label rather than a CalendarTitleButton, so it takes that
// one's inset and one-line clamp itself.
Text( Text(
text = stringResource(R.string.view_agenda), text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
) )
}, },
navigationIcon = { navigationIcon = {
@@ -459,16 +440,14 @@ private fun AgendaTopBar(
} }
ViewSwitcherPill( ViewSwitcherPill(
current = selectedView, current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView, onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
) )
}, },
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
scrollBehavior = scrollBehavior,
) )
} }

View File

@@ -1,64 +0,0 @@
package de.jeanlucmakiola.calendula.ui.agenda
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/** How far ahead the agenda preview's stand-in window runs. */
private const val PREVIEW_WINDOW_DAYS = 14
/**
* A live, scaled-down Agenda view for the onboarding view chooser. Today and
* the day headers are real; only the events are stand-ins.
*/
@Composable
internal fun AgendaViewPreview(
height: Dp,
modifier: Modifier = Modifier,
) {
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val state = remember(today, zone) { sampleAgendaState(today, zone) }
ScaledViewPreview(height = height, modifier = modifier) {
AgendaContent(
state = state,
// Stand-in events are all upcoming, so the past-event preference
// cannot change what the preview shows — keep it at the default.
pastDisplay = PastEventDisplay.SHOW,
showToday = true,
onRetry = {},
onEventClick = {},
onOpenDay = {},
modifier = Modifier.fillMaxSize(),
)
}
}
/** Sample agenda state, grouped through the same helper the live view uses. */
private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.Success {
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
return AgendaUiState.Success(
anchor = today,
today = today,
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
rangeIsOverride = false,
rangeEnd = windowEnd,
// The range bar belongs to the screen, not the list — the preview shows
// the list only.
showRangeBar = false,
zone = zone,
)
}

View File

@@ -0,0 +1,71 @@
package de.jeanlucmakiola.calendula.ui.calendars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The one-time notice that Calendula now follows the device's per-calendar
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
* switch those calendars back on — that would un-hide them everywhere else too.
*/
@HiltViewModel
class CalendarVisibilityNoticeViewModel @Inject constructor(
private val prefs: CalendarPrefs,
private val reconciler: CalendarVisibilityReconciler,
) : ViewModel() {
/**
* Reconcile whenever the app comes up with the calendar permission held,
* rather than off one grant route: a permission granted on Android's
* app-settings screen never reaches the permission screen's callback.
*/
fun reconcile() {
viewModelScope.launch { reconciler.run() }
}
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
.map { it == true }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = false,
)
fun dismiss() {
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
}
}
/** Plain informational dialog — one acknowledgement, nothing to decide. */
@Composable
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dialog_ok))
}
},
)
}

View File

@@ -1,40 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
/**
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
*
* Only the trailing edge and the title's start are shared; the leading edge and
* the gaps between the action icons stay on M3's defaults, which derive both
* from the icon buttons' width.
*/
object AppBarSpacing {
/** Side inset shared by the bars' trailing edge and the month grid. */
val Inset = 8.dp
/**
* Start inset on the title, taking it clear of the navigation icon M3 places
* it flush against. Real padding rather than a negative offset, which would
* swallow taps meant for the menu button.
*/
val TitleInset = 8.dp
/** M3's own padding around the actions row. */
internal val BarPadding = 4.dp
private val IconButtonSize = 48.dp
private val IconSize = 24.dp
/**
* End padding for a container-backed control that ends the bar, measured to
* its background. Coerced because [androidx.compose.foundation.layout.padding]
* throws on a negative value.
*/
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
/** Screen edge to the glyph of an icon button that ends the bar. */
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
}

View File

@@ -4,166 +4,30 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.snap import androidx.compose.animation.core.snap
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/** Gap a timed block leaves to its neighbours in the column. */
val BLOCK_OUTER_INSET = 1.dp
/** Padding between a timed block's edge and its text. */
val BLOCK_TEXT_PADDING = 4.dp
/** Most lines a time label may wrap over before it is worth more than a title line. */
const val MAX_TIME_LINES = 2
/**
* Lines [text] needs to render whole at [textWidth], capped at [max].
*
* Lets a block hand out its height by what the text actually asks for rather
* than by what would fit: a title that wants one line should not be given three
* that the time label could have used, and a week column is narrower than a
* "09:3011:00" range so the range should not be assumed to want one.
*/
@Composable
fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int {
val measurer = rememberTextMeasurer()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
return remember(text, style, widthPx, max, measurer) {
if (max <= 1 || widthPx <= 0) {
1
} else {
measurer.measure(
text = text,
style = style,
constraints = Constraints(maxWidth = widthPx),
).lineCount.coerceIn(1, max)
}
}
}
/**
* A timed block's title, over at most [maxLines].
*
* Wrapping and clipping pull against each other, which is why #164 left the
* ellipsis on multi-line chips: with `softWrap` on, the last visible line ends
* at a word boundary, so "Farmers Market" in a six-character column would clip
* to "Farmer" / "s" where the ellipsis at least reached "s Mar…".
*
* So the block wraps every line but the last through one `Text` and hands the
* remainder to a second that clips mid-glyph the way a single-line chip does.
* Every line is then full and none of them spends two of its few characters on
* a "…". RTL keeps the ellipsis for the reason [eventTitleOverflowFor] gives.
*/
@Composable
fun BlockTitle(
title: String,
maxLines: Int,
textWidth: Dp,
color: Color,
modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null,
) {
val style = MaterialTheme.typography.labelMedium
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val measurer = rememberTextMeasurer()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
// Where the wrapped lines stop and the clipped tail starts — null when the
// title fits, and nothing needs splitting.
val headEnd = remember(title, style, widthPx, maxLines, rtl, measurer) {
if (rtl || maxLines < 2 || widthPx <= 0) {
null
} else {
val layout = measurer.measure(
text = title,
style = style,
constraints = Constraints(maxWidth = widthPx),
)
if (layout.lineCount <= maxLines) {
null
} else {
layout.getLineEnd(maxLines - 2, visibleEnd = true)
}
}
}
if (headEnd == null) {
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Text(
text = title,
modifier = modifier,
style = style,
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color,
textDecoration = textDecoration,
)
} else {
val tail = eventTitleOverflow(singleLine = true)
Column(modifier = modifier) {
Text(
text = title.substring(0, headEnd),
style = style,
maxLines = maxLines - 1,
overflow = TextOverflow.Clip,
softWrap = true,
color = color,
textDecoration = textDecoration,
)
Text(
text = title.substring(headEnd).trimStart(),
style = style,
maxLines = 1,
overflow = tail.overflow,
softWrap = tail.softWrap,
color = color,
textDecoration = textDecoration,
)
}
}
}
/** /**
* A timed block's own time label, crossfaded rather than replaced — the block * A timed block's own time label, crossfaded rather than replaced — the block
* slides to its new slot, so the label shouldn't change in a single frame. * slides to its new slot, so the label shouldn't change in a single frame.
*
* Overflows like a title does (#164): the "…" costs two characters of a string
* that is nothing but characters, so the label clips at the block's edge
* instead. [maxLines] lets a narrow column spend spare height on the range
* rather than losing its end.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun BlockTimeLabel( fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
label: String,
color: Color,
modifier: Modifier = Modifier,
maxLines: Int = 1,
) {
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) { val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
snap() snap()
} else { } else {
MaterialTheme.motionScheme.fastEffectsSpec() MaterialTheme.motionScheme.fastEffectsSpec()
} }
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Crossfade( Crossfade(
targetState = label, targetState = label,
animationSpec = spec, animationSpec = spec,
@@ -173,9 +37,8 @@ fun BlockTimeLabel(
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = maxLines, maxLines = 1,
overflow = overflow.overflow, overflow = TextOverflow.Ellipsis,
softWrap = overflow.softWrap,
color = color, color = color,
) )
} }

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -13,7 +12,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -21,8 +19,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -35,9 +31,10 @@ import kotlinx.datetime.LocalDate
* [currentDate] seeds the picker with whatever the bar is currently naming (the * [currentDate] seeds the picker with whatever the bar is currently naming (the
* visible day, week start or month anchor). * visible day, week start or month anchor).
* *
* [shortTitle] replaces [title] when the full one does not fit the width the app * The row keeps its own 8.dp inset rather than shifting back onto the app bar's
* bar hands the title slot (#165). Either way the line clamps to one and * start alignment: M3 places the title flush against the navigation icon's
* ellipsises, so pass a [shortTitle] wherever there is something left to drop. * trailing edge, and the title is hit-tested on top of it, so a negative offset
* would swallow taps meant for the menu button.
*/ */
@Composable @Composable
fun CalendarTitleButton( fun CalendarTitleButton(
@@ -45,7 +42,6 @@ fun CalendarTitleButton(
currentDate: LocalDate, currentDate: LocalDate,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shortTitle: String = title,
) { ) {
var showDatePicker by rememberSaveable { mutableStateOf(false) } var showDatePicker by rememberSaveable { mutableStateOf(false) }
@@ -57,27 +53,14 @@ fun CalendarTitleButton(
onClickLabel = stringResource(R.string.drawer_jump_to_date), onClickLabel = stringResource(R.string.drawer_jump_to_date),
role = Role.Button, role = Role.Button,
) { showDatePicker = true } ) { showDatePicker = true }
.padding(horizontal = AppBarSpacing.TitleInset), .padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val style = MaterialTheme.typography.titleLarge
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
val measurer = rememberTextMeasurer()
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
titleFor(
title = title,
shortTitle = shortTitle,
titleWidth = measurer.measure(title, style).size.width,
availableWidth = constraints.maxWidth,
)
}
Text( Text(
text = shown, text = title,
style = style, style = MaterialTheme.typography.titleLarge,
maxLines = 1, modifier = Modifier.weight(1f, fill = false),
overflow = TextOverflow.Ellipsis,
) )
}
Icon( Icon(
imageVector = Icons.Default.ArrowDropDown, imageVector = Icons.Default.ArrowDropDown,
contentDescription = null, contentDescription = null,
@@ -96,11 +79,3 @@ fun CalendarTitleButton(
) )
} }
} }
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
internal fun titleFor(
title: String,
shortTitle: String,
titleWidth: Int,
availableWidth: Int,
): String = if (titleWidth <= availableWidth) title else shortTitle

View File

@@ -57,8 +57,7 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
* implemented view — the settings screen reorders the whole set — while [cycle] * implemented view — the settings screen reorders the whole set — while [cycle]
* is the subset the pill actually steps through, in [order]. The navigation * is the subset the pill actually steps through, in [order]. The navigation
* drawer keeps its own separate order and always lists every view, so a view * drawer keeps its own separate order and always lists every view, so a view
* disabled here stays reachable there — including when [cycle] is emptied and * disabled here stays reachable there.
* the pill disappears altogether.
*/ */
data class QuickSwitchConfig( data class QuickSwitchConfig(
val order: List<CalendarView>, val order: List<CalendarView>,
@@ -68,15 +67,14 @@ data class QuickSwitchConfig(
val cycle: List<CalendarView> get() = order.filter { it in enabled } val cycle: List<CalendarView> get() = order.filter { it in enabled }
companion object { companion object {
/**
* Fewest views that keep the switch meaningful. A single target is not a
* switch, so below this the pill is hidden rather than special-cased (#150);
* the drawer still reaches every view.
*/
const val MIN_CYCLE = 2
/** All views, in default order, all enabled. */ /** All views, in default order, all enabled. */
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet()) val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
/**
* Fewest views that keep the switch meaningful — a "switch" needs at
* least two targets, so the settings screen blocks disabling below this.
*/
const val MIN_ENABLED = 2
} }
} }

View File

@@ -1,104 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.ClipData
import android.os.Build
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.semantics
import de.jeanlucmakiola.calendula.R
import kotlinx.coroutines.launch
/** Puts one labelled field on the clipboard. */
fun interface FieldCopier {
operator fun invoke(label: String, text: String)
}
/**
* A [FieldCopier] that confirms the copy only where the system doesn't (#195).
*
* Android 13 raises its own clipboard chip for every copy, so a snackbar on top
* of it reads as the app having done the job twice. A failure is always worth a
* word, though: the clipboard can refuse a very long description outright.
*/
@Composable
fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val confirmation = stringResource(R.string.field_copied)
val failure = stringResource(R.string.field_copy_failed)
return remember(clipboard, scope, snackbarHostState, confirmation, failure) {
FieldCopier { label, text ->
scope.launch {
val message = runCatching {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, text)))
}.fold(
onSuccess = {
confirmation.takeIf {
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
}
},
onFailure = { failure },
)
if (message != null) {
// Copying twice in a row shouldn't queue two four-second
// confirmations — the newest one wins.
snackbarHostState.currentSnackbarData?.dismiss()
snackbarHostState.showSnackbar(message)
}
}
}
}
}
/**
* Long-press to copy [text] whole, filed on the clipboard under [label].
*
* [onTap] carries a field's existing tap action through; a field without one
* stays unclickable rather than growing a ripple that leads nowhere, and gets
* its long press announced through semantics instead. That branch merges the
* node it sits on, so a screen reader lands on the field itself and finds the
* action there — a bare container is never focused.
*/
@Composable
fun Modifier.copyOnLongPress(
label: String,
text: String,
copy: FieldCopier,
onTap: (() -> Unit)? = null,
): Modifier {
val actionLabel = stringResource(R.string.field_copy_action)
return if (onTap != null) {
combinedClickable(
onClick = onTap,
onLongClickLabel = actionLabel,
onLongClick = { copy(label, text) },
)
} else {
val haptics = LocalHapticFeedback.current
pointerInput(label, text, copy) {
detectTapGestures(
onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
copy(label, text)
},
)
}.semantics(mergeDescendants = true) {
onLongClick(actionLabel) {
copy(label, text)
true
}
}
}
}

View File

@@ -1,35 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
/** The corner radius every event chip keeps on each edge the event doesn't cross. */
val EVENT_CHIP_CORNER = 4.dp
/** An event chip that is cut on no edge — an all-day bar, a floating copy. */
val EventChipShape = RoundedCornerShape(EVENT_CHIP_CORNER)
/**
* A timed block's corners: square on whichever edge the event runs past, so a
* cut edge reads as "this carries on" rather than as the event's own end. The
* vertical counterpart of [monthBarShape].
*/
fun timedBlockShape(continuesBefore: Boolean, continuesAfter: Boolean): RoundedCornerShape =
RoundedCornerShape(
topStart = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER,
topEnd = if (continuesBefore) 0.dp else EVENT_CHIP_CORNER,
bottomStart = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER,
bottomEnd = if (continuesAfter) 0.dp else EVENT_CHIP_CORNER,
)
/**
* A month/all-day bar's corners: square on whichever side the event runs past
* the row it is drawn in.
*/
fun monthBarShape(continuesLeft: Boolean, continuesRight: Boolean): RoundedCornerShape =
RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER,
bottomStart = if (continuesLeft) 0.dp else EVENT_CHIP_CORNER,
topEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER,
bottomEnd = if (continuesRight) 0.dp else EVENT_CHIP_CORNER,
)

View File

@@ -29,18 +29,8 @@ class EventMoveScope(
* the repository writes whatever it is handed — so this gate is load-bearing. * the repository writes whatever it is handed — so this gate is load-bearing.
*/ */
val movableCalendarIds: Set<Long>, val movableCalendarIds: Set<Long>,
/** /** False when the drop was refused outright, so nothing will be written. */
* Whether the *gesture* is on (#173). Off gates the drag alone: [edit] and the val move: (MoveRequest) -> Boolean,
* TalkBack action below stay, since invoking a menu item can't cause the
* accidental drags the setting exists to stop.
*/
val dragEnabled: Boolean,
/**
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered
* before its write resolves, so [abandoned] — not a return value — is what
* tells a held copy that nothing landed.
*/
val move: (MoveRequest) -> Unit,
/** /**
* True while a dropped event is being written, including the time its scope * True while a dropped event is being written, including the time its scope
* dialog is up. A flow rather than a value so this scope stays the same * dialog is up. A flow rather than a value so this scope stays the same
@@ -49,12 +39,6 @@ class EventMoveScope(
val inFlight: StateFlow<Boolean>, val inFlight: StateFlow<Boolean>,
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */ /** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
val undoStarted: StateFlow<Int>, val undoStarted: StateFlow<Int>,
/**
* Ticks when a drop ends with nothing landing — see
* `RescheduleViewModel.abandoned`. [move] answers before the write is
* resolved, so this is what tells a held copy to stop waiting.
*/
val abandoned: StateFlow<Int>,
/** Open an event in the edit form — the pointer-free route to the same change. */ /** Open an event in the edit form — the pointer-free route to the same change. */
val edit: (EventInstance) -> Unit, val edit: (EventInstance) -> Unit,
) { ) {
@@ -67,8 +51,6 @@ private val NEVER_IN_FLIGHT = MutableStateFlow(false)
private val NEVER_UNDONE = MutableStateFlow(0) private val NEVER_UNDONE = MutableStateFlow(0)
private val NEVER_ABANDONED = MutableStateFlow(0)
/** /**
* Whether a dropped event is still being written — false wherever moving is off. * Whether a dropped event is still being written — false wherever moving is off.
* The drag overlays hold a landed block on its target for this window. * The drag overlays hold a landed block on its target for this window.
@@ -79,17 +61,6 @@ fun moveInFlight(): Boolean {
return flow.collectAsStateWithLifecycle().value return flow.collectAsStateWithLifecycle().value
} }
/**
* How many drops have ended with nothing landing. Read as a plain count so a
* drag overlay can capture it at the moment of the drop and tell, later, whether
* *its own* drop was one of them.
*/
@Composable
fun abandonedMoves(): Int {
val flow = LocalEventMove.current?.abandoned ?: NEVER_ABANDONED
return flow.collectAsStateWithLifecycle().value
}
/** /**
* Runs [onUndo] when an undo write begins, and never for one that began before * Runs [onUndo] when an undo write begins, and never for one that began before
* this composable came on screen. * this composable came on screen.
@@ -120,17 +91,6 @@ fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
label = "ghost-alpha", label = "ghost-alpha",
).value ).value
/**
* Whether [event] can be picked up and dragged — its calendar allows moving and
* the gesture is switched on. Not the same question as [eventMoveAction], which
* survives the setting.
*/
@Composable
fun eventDragAllowed(event: EventInstance): Boolean {
val move = LocalEventMove.current ?: return false
return move.dragEnabled && move.allows(event)
}
/** /**
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't * A TalkBack action that opens [event] in the edit form, so rescheduling isn't
* pointer-only. Null when this event can't be moved. * pointer-only. Null when this event can't be moved.

View File

@@ -1,39 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
/** How an event chip's title should overflow: what to pass to `Text`. */
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
/**
* Overflow for an event chip's title (#164). Chips are narrow enough that the
* "…" costs a couple of readable characters, so the title runs to the chip's
* edge and clips mid-glyph instead.
*
* Two cases keep the ellipsis:
*
* - **[rtl].** With `softWrap` off Compose lays the line out at its full
* intrinsic width and clips to the node's left edge, which in RTL is the *end*
* of the string — an Arabic title would lose its beginning. The ellipsis
* truncates at the logical end in both directions.
* - **More than one line** ([singleLine] false). Wrapping needs `softWrap` on,
* and clipping with it on breaks the last line at the last whole word — less
* title than the ellipsis showed, not more.
*/
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
if (rtl || !singleLine) {
EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
} else {
EventTitleOverflow(TextOverflow.Clip, softWrap = false)
}
/** [eventTitleOverflowFor] against the current layout direction. */
@Composable
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
eventTitleOverflowFor(
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
singleLine = singleLine,
)

View File

@@ -36,14 +36,6 @@ val GUTTER_WIDTH = 48.dp
*/ */
val GUTTER_CONTENT_START_INSET = 8.dp val GUTTER_CONTENT_START_INSET = 8.dp
/**
* End inset for everything that lines up with the day columns — the week's day
* header, the all-day strip and the timeline itself. Without it the last column
* runs flush into the screen edge while the gutter gives the other side room
* (#192); 8dp mirrors [GUTTER_CONTENT_START_INSET].
*/
val TIMELINE_CONTENT_END_INSET = 8.dp
private val BADGE_HEIGHT = 20.dp private val BADGE_HEIGHT = 20.dp
/** How far the fixed hour labels recede while a block is being dragged. */ /** How far the fixed hour labels recede while a block is being dragged. */

View File

@@ -1,10 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import kotlin.time.Instant import kotlin.time.Instant
/** /**
@@ -18,30 +14,3 @@ val LocalDimCutoff = compositionLocalOf<Instant?> { null }
/** Opacity applied to a completed/past event chip when it is dimmed. */ /** Opacity applied to a completed/past event chip when it is dimmed. */
const val EventDimAlpha = 0.4f const val EventDimAlpha = 0.4f
/**
* How a declined invitation's title is struck through (#180) — the mark every
* surface uses to say "you answered no", chosen over hiding the event because it
* is still something the organiser expects you at. Null for everything else, so
* it drops straight into a `Text`'s `textDecoration`.
*/
fun declinedDecoration(isDeclined: Boolean): TextDecoration? =
if (isDeclined) TextDecoration.LineThrough else null
/** [declinedDecoration] for the rows that take styled text rather than a `String`. */
fun declinedTitle(title: String, isDeclined: Boolean): AnnotatedString = if (isDeclined) {
AnnotatedString(title, SpanStyle(textDecoration = TextDecoration.LineThrough))
} else {
AnnotatedString(title)
}
/** [declinedTitle] over already-styled text, e.g. a search hit's marked runs. */
fun declinedTitle(title: AnnotatedString, isDeclined: Boolean): AnnotatedString =
if (!isDeclined) {
title
} else {
buildAnnotatedString {
append(title)
addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough), 0, title.length)
}
}

View File

@@ -1,169 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* Stand-in events for the view previews. A preview has no business querying the
* provider for a thumbnail, so every chooser renders the same invented week.
*
* Colours are raw ARGB on purpose: that is what the provider hands out for an
* event, so a theme token here would misrepresent what the views actually draw.
*/
private val SAMPLE_COLORS = listOf(
0xFF3F7BD4.toInt(),
0xFFCE5B4C.toInt(),
0xFF4E9A6A.toInt(),
0xFF8A63C7.toInt(),
)
private val SAMPLE_TITLES = listOf(
"Standup",
"Lunch",
"Review",
"Gym",
"Call",
"Workshop",
"Dentist",
"Trip",
)
/** Hands out [EventInstance]s with running ids and cycling sample titles/colours. */
private class SampleEvents(private val zone: TimeZone) {
private var id = 0L
fun timed(
day: LocalDate,
hour: Int,
minute: Int = 0,
lengthMinutes: Int,
colorIndex: Int,
): EventInstance {
val start = day.atTime(hour, minute).toInstant(zone)
return build(
start = start,
end = start + lengthMinutes.minutes,
isAllDay = false,
colorIndex = colorIndex,
)
}
fun allDay(from: LocalDate, days: Int, colorIndex: Int): EventInstance = build(
// 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,
colorIndex = colorIndex,
)
private fun build(
start: Instant,
end: Instant,
isAllDay: Boolean,
colorIndex: Int,
): EventInstance {
val next = ++id
return EventInstance(
instanceId = next,
eventId = next,
calendarId = 1L,
title = SAMPLE_TITLES[(next.toInt() - 1) % SAMPLE_TITLES.size],
start = start,
end = end,
isAllDay = isAllDay,
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
location = null,
)
}
}
/**
* A month's worth of stand-ins, spread so most weeks carry something, with one
* multi-day bar bridging cells and a busy today for the split pane.
*/
internal fun sampleMonthEvents(
firstOfMonth: LocalDate,
today: LocalDate,
zone: TimeZone,
): List<EventInstance> {
val sample = SampleEvents(zone)
return buildList {
add(sample.allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
add(sample.timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, lengthMinutes = 120, colorIndex = 1))
add(sample.timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
add(sample.timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, lengthMinutes = 60, colorIndex = 2))
add(sample.timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, lengthMinutes = 120, colorIndex = 1))
add(sample.timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, lengthMinutes = 60, colorIndex = 3))
// Today, so the split pane's list and the grid's dots both have content.
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(today, 12, lengthMinutes = 60, colorIndex = 1))
add(sample.timed(today, 15, lengthMinutes = 120, colorIndex = 3))
}
}
/**
* Stand-ins for a timeline preview over [days]. [anchor] — the day the preview
* is centred on — gets the busy set, including one overlapping pair so the
* side-by-side lane layout shows; the rest of the week is sparser. A multi-day
* all-day bar is only added when there is more than one column to span.
*/
internal fun sampleTimelineEvents(
days: List<LocalDate>,
anchor: LocalDate,
zone: TimeZone,
): List<EventInstance> {
val sample = SampleEvents(zone)
return buildList {
if (days.size > 1) {
add(sample.allDay(days[days.size / 2], days = 2, colorIndex = 2))
} else {
add(sample.allDay(anchor, days = 1, colorIndex = 2))
}
// The anchor day: enough to fill the column, with 09:3011:00 running
// under 09:0010:00 so the overlap resolves into two lanes.
add(sample.timed(anchor, 9, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(anchor, 9, minute = 30, lengthMinutes = 90, colorIndex = 1))
add(sample.timed(anchor, 13, lengthMinutes = 120, colorIndex = 3))
days.filterNot { it == anchor }.forEachIndexed { index, day ->
// Skip every third day so the week doesn't read as a solid block.
if (index % 3 == 2) return@forEachIndexed
add(
sample.timed(
day = day,
hour = 8 + (index * 3) % 9,
lengthMinutes = if (index % 2 == 0) 60 else 90,
colorIndex = index,
),
)
}
}
}
/**
* Stand-ins for the agenda preview: a forward-looking fortnight where the first
* days are busy and later ones thin out, so the grouped-by-day list shows both
* a full day and the gaps between days.
*/
internal fun sampleAgendaEvents(today: LocalDate, zone: TimeZone): List<EventInstance> {
val sample = SampleEvents(zone)
return buildList {
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(today, 12, minute = 30, lengthMinutes = 60, colorIndex = 1))
add(sample.timed(today, 16, lengthMinutes = 90, colorIndex = 3))
val tomorrow = today.plus(1, DateTimeUnit.DAY)
add(sample.allDay(tomorrow, days = 2, colorIndex = 2))
add(sample.timed(tomorrow, 10, lengthMinutes = 120, colorIndex = 1))
add(sample.timed(today.plus(3, DateTimeUnit.DAY), 14, lengthMinutes = 60, colorIndex = 0))
add(sample.timed(today.plus(6, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
add(sample.timed(today.plus(10, DateTimeUnit.DAY), 18, lengthMinutes = 120, colorIndex = 2))
}
}

View File

@@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.MINUTES_DEFAULT
import de.jeanlucmakiola.floret.reminders.ReminderUnit import de.jeanlucmakiola.floret.reminders.ReminderUnit
/** Common reminder lead times offered as quick picks in the form and settings. */ /** Common reminder lead times offered as quick picks in the form and settings. */
@@ -21,20 +20,15 @@ fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
/** /**
* Humanise a reminder lead time (minutes before the event start) into one * Humanise a reminder lead time (minutes before the event start) into one
* line: "Default reminder" ([MINUTES_DEFAULT]), "At time of event" (0), * line: "Default reminder" (negative = the provider default), "At time of
* "10 minutes before", "1 hour before", … Shared by the detail screen, the event * event" (0), "10 minutes before", "1 hour before", … Shared by the detail
* form and the default-reminder settings so the wording never drifts. * screen, the event form and the default-reminder settings so the wording
* * never drifts.
* Only the sentinel itself is the account default. Any other negative is a real
* lead time firing *after* the start — rare, but a foreign all-day row decodes to
* one — and naming it "Default reminder" would tell the user an alarm that does
* go off is not ours.
*/ */
@Composable @Composable
fun reminderLeadTimeLabel(minutes: Int): String = when { fun reminderLeadTimeLabel(minutes: Int): String = when {
minutes == MINUTES_DEFAULT -> stringResource(R.string.reminder_default) minutes < 0 -> stringResource(R.string.reminder_default)
minutes == 0 -> stringResource(R.string.reminder_at_time) minutes == 0 -> stringResource(R.string.reminder_at_time)
minutes < 0 -> stringResource(R.string.reminder_after, durationLabel(-minutes))
minutes % 10_080 == 0 -> minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080) pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 -> minutes % 1_440 == 0 ->

View File

@@ -140,17 +140,6 @@ class RescheduleViewModel @Inject constructor(
*/ */
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow() val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
private val _abandoned = MutableStateFlow(0)
/**
* Ticks when a drop ends with nothing on the target day: refused outright,
* refused before the write, written and failed, or its scope dialog
* dismissed. [move] answers before any of that is known, so without this
* signal the view that drew the drop held its copy out for the full settle
* timeout waiting for a grid that was never going to draw it (#253).
*/
val abandoned: StateFlow<Int> = _abandoned.asStateFlow()
private val _undoStarted = MutableStateFlow(0) private val _undoStarted = MutableStateFlow(0)
/** /**
@@ -200,23 +189,15 @@ class RescheduleViewModel @Inject constructor(
/** /**
* Take a drop, unless one is already being written. False means nothing will * Take a drop, unless one is already being written. False means nothing will
* be written; [abandoned] ticks for that refusal too, so a held copy is let * be written and the caller must let its held copy go now — waiting on
* go by the one signal rather than by every caller remembering to. * [inFlight] would strand it until the settle timeout instead.
*
* The Boolean is diagnostic — the tests read it, [EventMoveScope] does not
* hand it on. A caller that gated on it would be back to releasing its copy
* from two places, and it answers before the write is resolved anyway.
*/ */
fun move(request: MoveRequest): Boolean { fun move(request: MoveRequest): Boolean {
if (busy || _scopePrompt.value != null) { if (busy || _scopePrompt.value != null) return false
_abandoned.value += 1
return false
}
busy = true busy = true
viewModelScope.launch { viewModelScope.launch {
val prepared = prepare(request) val prepared = prepare(request)
if (prepared == null) { if (prepared == null) {
_abandoned.value += 1
busy = false busy = false
return@launch return@launch
} }
@@ -250,7 +231,6 @@ class RescheduleViewModel @Inject constructor(
pending = null pending = null
busy = false busy = false
_scopePrompt.value = null _scopePrompt.value = null
_abandoned.value += 1
} }
/** /**
@@ -258,12 +238,6 @@ class RescheduleViewModel @Inject constructor(
* inverse write reports back, so the one chip changes what it says rather * inverse write reports back, so the one chip changes what it says rather
* than closing and reopening. False when another write is already running — * than closing and reopening. False when another write is already running —
* the chip hides its action for that window, so this is the backstop. * the chip hides its action for that window, so this is the backstop.
*
* [abandoned] ticks once an inverse that *started* fails to land: [undoStarted]
* has sent the chip back on its journey by then and nothing else will seat it.
* A refusal ticks nothing — it launched no journey of its own, and the counter
* is global, so a tick here would release some other drop's held copy before
* the grid has drawn it.
*/ */
fun undo(undo: MoveUndo): Boolean { fun undo(undo: MoveUndo): Boolean {
if (busy) return false if (busy) return false
@@ -282,9 +256,6 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
MoveOutcome.Failed MoveOutcome.Failed
} }
// Read off the outcome, the way [write] does — no second copy of the
// same fact to drift from it.
if (_outcome.value !is MoveOutcome.Undone) _abandoned.value += 1
busy = false busy = false
} }
return true return true
@@ -369,7 +340,6 @@ class RescheduleViewModel @Inject constructor(
val request = prepared.request val request = prepared.request
if (endsBeforeItStarts(prepared, scope)) { if (endsBeforeItStarts(prepared, scope)) {
_outcome.value = MoveOutcome.BlockedSeriesEnd _outcome.value = MoveOutcome.BlockedSeriesEnd
_abandoned.value += 1
return return
} }
_outcome.value = try { _outcome.value = try {
@@ -381,7 +351,6 @@ class RescheduleViewModel @Inject constructor(
repository.updateOccurrence( repository.updateOccurrence(
request.eventId, request.eventId,
request.beginMillis, request.beginMillis,
prepared.original,
prepared.updated, prepared.updated,
) )
@@ -406,9 +375,6 @@ class RescheduleViewModel @Inject constructor(
} catch (e: Exception) { } catch (e: Exception) {
MoveOutcome.Failed MoveOutcome.Failed
} }
// Nothing landed, so the copy the view is holding has nothing to fade
// into — let it go now rather than at the end of the settle timeout.
if (_outcome.value !is MoveOutcome.Moved) _abandoned.value += 1
} }
/** /**

View File

@@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
@@ -34,6 +33,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -44,11 +44,7 @@ import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -70,148 +66,17 @@ private val AUTO_SCROLL_STEP = 16.dp
data class TimelineDrag( data class TimelineDrag(
val event: EventInstance, val event: EventInstance,
val date: LocalDate, val date: LocalDate,
/**
* Where the dragged *block's* top edge now sits, in minutes from [date]'s
* midnight. The gesture's own quantity — what snaps, what the haptics tick
* on, and what the drop is expressed in. Not the event's start: for the tail
* of an event that began the day before, the two are a clip offset apart.
*/
val startMin: Int, val startMin: Int,
/** The event's new start, from [date]'s midnight — negative if it began earlier. */ val endMin: Int,
val eventStartMin: Int, val topLeftInRoot: Offset,
val eventSpanMin: Int, val sizePx: IntSize,
/** The event as the grid would draw it, one piece per day column it covers. */
val pieces: List<TimelineDragPiece>,
/**
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
* every column this timeline shows. A hint under the finger, not where the
* drop lands, so it is dropped once the gesture ends ([finish]).
*/
val edgeOnly: Boolean = false,
) { ) {
/** What the snap haptics key off: one tick per changed slot, not per frame. */ /** What the snap haptics key off: one tick per changed slot, not per frame. */
val slot: Pair<LocalDate, Int> get() = date to startMin val slot: Pair<LocalDate, Int> get() = date to startMin
} }
/** /** Where a finished drag asks its event to go. */
* One day column's share of a dragged event, ready to draw. Both halves of an data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
* event crossing midnight are previewed, whichever half the finger picked up:
* showing only the held one made the event read as growing rather than moving,
* since the day beside it still stood at the block's old extent (#253).
*/
data class TimelineDragPiece(
val topLeftInRoot: Offset,
val sizePx: IntSize,
val continuesBefore: Boolean,
val continuesAfter: Boolean,
)
/**
* How a run of [spanMin] minutes beginning [eventStartMin] minutes into a day —
* negative when it began on an earlier one — falls across day columns, as
* offsets from that day. Every slice but the first and last is a whole day, and
* the flags say which edges are cuts rather than the event's own ends.
*
* [within] bounds the day offsets that have a column to draw in, so a very long
* event doesn't slice days no one can see.
*/
internal fun dragSlices(
eventStartMin: Int,
spanMin: Int,
within: IntRange? = null,
): List<DragSlice> {
val span = spanMin.coerceAtLeast(0)
val end = eventStartMin + span
val days = dragDayRange(eventStartMin, spanMin)
val first = days.first
val last = days.last
val from = maxOf(first, within?.first ?: first)
val to = minOf(last, within?.last ?: last)
if (from > to) return emptyList()
return (from..to).map { day ->
val dayStart = day * MINUTES_PER_DAY
val sliceStart = maxOf(eventStartMin - dayStart, 0)
val sliceEnd = minOf(end - dayStart, MINUTES_PER_DAY)
DragSlice(
dayOffset = day,
startMin = sliceStart,
spanMin = sliceEnd - sliceStart,
continuesBefore = day > first,
continuesAfter = day < last,
)
}
}
/**
* The one piece to draw when a drag has carried the whole event clear of the
* columns this timeline shows, so [dragSlices] has nothing to hand back — where a
* short clipped tail lands at its [dragFloorMin]: the event then belongs to the
* day before, which Day view has no column for, and the copy would blink out from
* under the finger mid-gesture. Pinned to the top of the nearest visible column
* at minimum height instead, cut at that edge, so it reads as having slid off the
* top. Null whenever a real slice exists. The drop is unaffected either way — it
* reads [TimelineDrag.startMin], not the pieces.
*/
internal fun edgeDragSlice(eventStartMin: Int, spanMin: Int, within: IntRange): DragSlice? {
if (dragDayRange(eventStartMin, spanMin).last >= within.first) return null
return DragSlice(
dayOffset = within.first,
startMin = 0,
spanMin = 0,
continuesBefore = true,
continuesAfter = false,
)
}
/**
* The day offsets a run of [spanMin] minutes beginning [eventStartMin] covers,
* before any clipping to what a timeline shows. One rule, so [dragSlices] and
* [edgeDragSlice] can't drift apart on where an event's days begin and end.
*/
private fun dragDayRange(eventStartMin: Int, spanMin: Int): IntRange {
val end = eventStartMin + spanMin.coerceAtLeast(0)
val first = eventStartMin.floorDiv(MINUTES_PER_DAY)
// A zero-length event still has a block, on the one day it sits in.
return first..maxOf((end - 1).floorDiv(MINUTES_PER_DAY), first)
}
/**
* How far above its day's midnight a block's top edge may be dragged. Zero for
* an ordinary block, whose top edge is the event's own start. A tail clipped at
* midnight has to go lower: its top edge *is* midnight and stays there however
* much earlier the event moves, so pinning it at zero made dragging the second
* half earlier a no-op (#253). It may rise until the event's end reaches one
* snap step into this day — or by a single step, for a tail too short to afford
* even that, which then leaves the day for the one before it.
*/
internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
if (clipOffsetMin > 0) {
minOf(-DRAG_SNAP_MINUTES, DRAG_SNAP_MINUTES + clipOffsetMin - eventSpanMin)
} else {
0
}
/** One day's share of a dragged event, before it is placed on screen. */
internal data class DragSlice(
val dayOffset: Int,
val startMin: Int,
val spanMin: Int,
val continuesBefore: Boolean,
val continuesAfter: Boolean,
)
/**
* Where a finished drag asks its event to go. [date]/[startMin] are where the
* dragged *block's* top edge landed, which is the event's own start only for a
* block that isn't clipped — see [clipOffsetMin] and [startInstant].
*/
data class TimelineDrop(
val event: EventInstance,
val date: LocalDate,
val startMin: Int,
/** Minutes from the event's start to the dragged block's top edge. */
val clipOffsetMin: Int = 0,
)
/** /**
* The timeline's live geometry, republished on every layout. Plain fields rather * The timeline's live geometry, republished on every layout. Plain fields rather
@@ -269,14 +134,12 @@ class TimelineDragController {
private set private set
/** /**
* The event row and start instant the settled drop came from. The instance id * Where the settled drop came from, and which event row it belongs to. The
* alone can't identify the source for the length of the write: the provider * instance id alone can't identify the source block for the length of the
* regenerates `Instances` rows, so a re-read carrying the *old* time can * write: the provider regenerates `Instances` rows, so a re-read carrying the
* arrive under a new instance id. Matching the start instant rather than a * *old* time can arrive under a new instance id.
* single day and slot keeps *both* halves of an event that crosses midnight
* ghosted until the write lands (#253).
*/ */
private var settledOrigin: Pair<Long, Instant>? by mutableStateOf(null) private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
/** Whether a finger is on a block right now — [settling] is not dragging. */ /** Whether a finger is on a block right now — [settling] is not dragging. */
var isDragging: Boolean by mutableStateOf(false) var isDragging: Boolean by mutableStateOf(false)
@@ -290,42 +153,18 @@ class TimelineDragController {
var settledOnGrid: Boolean by mutableStateOf(false) var settledOnGrid: Boolean by mutableStateOf(false)
private set private set
/**
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
*/
var abandonedTicks: StateFlow<Int>? = null
/** What [abandonedTicks] stood at when the settling drop was let go. */
var settledAbandonedMark: Int = 0
private set
private var source: TimedBlock? = null private var source: TimedBlock? = null
private var grab = Offset.Zero private var grab = Offset.Zero
private var pointer = Offset.Zero private var pointer = Offset.Zero
/**
* How far the picked-up block's top edge sits past the event's own start —
* nonzero only for a block clipped at midnight, which is the tail of an
* event that began the day before (#253). The gesture tracks the block the
* finger is on; this is what turns where it lands back into an event start.
*/
private var clipOffsetMin = 0
/** /**
* The slot the block already occupied when it was picked up — not the same * The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00). * as its start, since the target snaps to the grid (09:07 lifts to 09:00).
*/ */
private var originSlot: Pair<LocalDate, Int>? = null private var originSlot: Pair<LocalDate, Int>? = null
fun begin( fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
block: TimedBlock,
clipOffsetMin: Int,
pointerInRoot: Offset,
blockInRoot: Offset,
) {
source = block source = block
this.clipOffsetMin = clipOffsetMin
settling = null settling = null
isDragging = true isDragging = true
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
@@ -343,7 +182,6 @@ class TimelineDragController {
fun cancel() { fun cancel() {
source = null source = null
clipOffsetMin = 0
isDragging = false isDragging = false
liftedInstanceId = null liftedInstanceId = null
originSlot = null originSlot = null
@@ -354,42 +192,33 @@ class TimelineDragController {
} }
/** /**
* Whether [block] is part of the event whose copy is in flight, and so must * Whether [block] is the one whose copy is in flight, and so must stay a
* stay a ghost: the instance the finger picked up, or once dropped whatever * ghost: the instance the finger picked up, or once dropped whatever now
* the re-read now carries at the time it came from — both halves of it, for * sits in the slot it left.
* an event that crosses midnight (#253).
*/ */
fun ghosts(block: TimedBlock): Boolean { fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
if (liftedInstanceId == null) return false if (liftedInstanceId == null) return false
if (block.event.instanceId == liftedInstanceId) return true if (block.event.instanceId == liftedInstanceId) return true
val (eventId, start) = settledOrigin ?: return false val (eventId, originDate, originMin) = settledOrigin ?: return false
return block.event.eventId == eventId && block.event.start == start return block.event.eventId == eventId &&
date == originDate &&
block.startMin == originMin
} }
/** /**
* What a day column now holds, so a settled drop can tell when the grid has * What a day column now holds, so a settled drop can tell when the grid has
* caught up with it. Matched on the landing slot plus either the event row * caught up with it. Matched on the landing slot plus either the event row
* or its title, since a single-occurrence move writes a new `eventId`. A * or its title, since a single-occurrence move writes a new `eventId`.
* blank title matches nothing: every untitled event has one (#253).
*/ */
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) { fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
val landed = settling ?: return val landed = settling ?: return
if (settledOnGrid) return if (settledOnGrid || landed.date != date) return
// Where this day would draw it, which for a drop clipped at midnight is
// the day's own start rather than the edge the finger held (#253) — and
// which for a tail dragged clear of its day is the day before it.
val offset = (date.toEpochDays() - landed.date.toEpochDays()).toInt()
val slice = dragSlices(landed.eventStartMin, landed.eventSpanMin, offset..offset)
.firstOrNull() ?: return
settledOnGrid = blocks.any { block -> settledOnGrid = blocks.any { block ->
block.startMin == slice.startMin && block.startMin == landed.startMin &&
( (
block.event.eventId == landed.event.eventId || block.event.eventId == landed.event.eventId ||
(
landed.event.title.isNotBlank() &&
block.event.title == landed.event.title block.event.title == landed.event.title
) )
)
} }
} }
@@ -401,18 +230,12 @@ class TimelineDragController {
fun finish(): TimelineDrop? { fun finish(): TimelineDrop? {
val landed = drag val landed = drag
val origin = originSlot val origin = originSlot
val clipOffset = clipOffsetMin
cancel() cancel()
if (landed == null || landed.slot == origin) return null if (landed == null || landed.slot == origin) return null
// An edge piece is a hint for the finger, not a preview of the landing settling = landed
// slot: the event belongs to a day this timeline has no column for, so
// [noteGrid] can never seat it and it would sit at the top of today for
// the whole settle timeout, long after the event moved off-screen.
settling = if (landed.edgeOnly) landed.copy(pieces = emptyList()) else landed
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledOrigin = landed.event.eventId to landed.event.start settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
settledAbandonedMark = abandonedTicks?.value ?: 0 return TimelineDrop(landed.event, landed.date, landed.startMin)
return TimelineDrop(landed.event, landed.date, landed.startMin, clipOffset)
} }
/** /**
@@ -448,47 +271,28 @@ class TimelineDragController {
val origin = grid.positionInRoot() val origin = grid.positionInRoot()
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
val eventSpan = (block.event.end - block.event.start).inWholeMinutes.toInt() // Clamp the start into the target day; a tail past midnight is fine.
.coerceAtLeast(0) val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
val floor = dragFloorMin(clipOffsetMin, eventSpan) // The event's own length, not the block's: TimedBlock.endMin is clipped
val startMin = snapped.coerceIn(floor, MINUTES_PER_DAY - DRAG_SNAP_MINUTES) // at midnight, which would draw a 22:0002:00 event as a two-hour copy.
val eventStartMin = startMin - clipOffsetMin val span = (block.event.end - block.event.start).inWholeMinutes.toInt().coerceAtLeast(0)
// The column the finger is over on screen, and the day that column shows.
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex) val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
val visible = -dayIndex..days.lastIndex - dayIndex val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
val onGrid = dragSlices(eventStartMin, eventSpan, within = visible)
val slices = onGrid.ifEmpty {
listOfNotNull(edgeDragSlice(eventStartMin, eventSpan, visible))
}
drag = TimelineDrag( drag = TimelineDrag(
event = block.event, event = block.event,
date = days[dayIndex], date = days[dayIndex],
startMin = startMin, startMin = startMin,
eventStartMin = eventStartMin, endMin = startMin + span,
eventSpanMin = eventSpan,
// Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected.
pieces = slices
.map { slice ->
val day = dayIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - day else day
TimelineDragPiece(
topLeftInRoot = Offset( topLeftInRoot = Offset(
x = origin.x + col * columnPx, x = origin.x + column * columnPx,
y = origin.y + slice.startMin / 60f * hourPx, y = origin.y + startMin / 60f * hourPx,
), ),
sizePx = IntSize( sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(), (columnPx - geometry.columnGapPx).roundToInt(),
maxOf( height.roundToInt(),
slice.spanMin / 60f * hourPx,
MIN_EVENT_FRACTION * hourPx,
).roundToInt(),
), ),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
},
edgeOnly = onGrid.isEmpty(),
) )
} }
@@ -530,52 +334,24 @@ fun rememberTimelineDragController(): TimelineDragController {
val density = LocalDensity.current val density = LocalDensity.current
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() } controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() } controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller return controller
} }
/** /**
* How far this block's top edge sits past its event's start, in minutes — zero * True when [block] actually begins on [day] rather than being the tail of an
* for a block that begins on [day], and the part already run for the tail of an * event that started earlier. A clipped block's top edge is midnight, not the
* event that started earlier, whose top edge is midnight rather than the event's * event's start, so dragging it would move the event to a time it never had.
* start. Dragging such a tail moves the event by where *its own* top edge lands,
* so the offset has to come back off at the drop (#253).
*/ */
fun TimedBlock.clipOffsetMinutes(day: LocalDate, zone: TimeZone): Int { fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
val start = event.start.toLocalDateTime(zone) event.start.toLocalDateTime(zone).date == day
val days = (day.toEpochDays() - start.date.toEpochDays()).toInt()
val minutes = days * MINUTES_PER_DAY + startMin - start.time.toSecondOfDay() / 60
return minutes.coerceAtLeast(0)
}
/** /**
* True when the event began before [day] — this block is its tail, cut at * The instant a drop asks for, read in the zone the timeline is drawn in. A drop
* midnight rather than starting where the event does. * into a spring-forward gap has no instant of its own; `toInstant` resolves it
* forward by the missing hour, and the block visibly settles there.
*/ */
fun TimedBlock.continuesBefore(day: LocalDate, zone: TimeZone): Boolean = fun TimelineDrop.startInstant(zone: TimeZone): Instant =
event.start < day.atStartOfDayIn(zone) LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
/** True when the event runs past [day]'s midnight, so this block is cut there. */
fun TimedBlock.continuesAfter(day: LocalDate, zone: TimeZone): Boolean =
event.end > day.plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone)
/**
* The instant a drop asks for — the *event's* new start, so the tail of an
* overnight event drops back onto the day its own start belongs to. Read in the
* zone the timeline is drawn in. A drop into a spring-forward gap has no instant
* of its own; `toInstant` resolves it forward by the missing hour, and the block
* visibly settles there.
*/
fun TimelineDrop.startInstant(zone: TimeZone): Instant {
// Wall clock throughout, and past this day at either end: a block's top edge
// can be dragged above its midnight and a tail's below the next one, so the
// start is resolved as a day plus a minute of it rather than as an offset
// from an instant — which would shift by an hour across a DST boundary.
val fromMidnight = startMin - clipOffsetMin
val day = date.plus(fromMidnight.floorDiv(MINUTES_PER_DAY), DateTimeUnit.DAY)
val minute = fromMidnight.mod(MINUTES_PER_DAY)
return LocalDateTime(day, LocalTime(minute / 60, minute % 60)).toInstant(zone)
}
/** /**
* A beat after the grid draws the drop, so its own block is under way before the * A beat after the grid draws the drop, so its own block is under way before the
@@ -608,6 +384,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() val locale = currentLocale()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
val density = LocalDensity.current
val moveInFlight = moveInFlight() val moveInFlight = moveInFlight()
// Both live here rather than beside the controller: they read [drag], which // Both live here rather than beside the controller: they read [drag], which
@@ -620,14 +397,9 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
// recurring drop's scope dialog is up — and then until the grid draws the // recurring drop's scope dialog is up — and then until the grid draws the
// drop itself. // drop itself.
var handingOver by remember(controller.settling) { mutableStateOf(false) } var handingOver by remember(controller.settling) { mutableStateOf(false) }
val abandoned = abandonedMoves() LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid, abandoned) {
if (controller.settling == null || moveInFlight) return@LaunchedEffect if (controller.settling == null || moveInFlight) return@LaunchedEffect
// Past the mark this drop was let go on, it was abandoned: nothing will
// land and there is nothing to wait for.
if (abandoned == controller.settledAbandonedMark) {
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS) delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
}
handingOver = true handingOver = true
controller.handOver() controller.handOver()
delay(SETTLE_FADE_MILLIS.toLong()) delay(SETTLE_FADE_MILLIS.toLong())
@@ -656,92 +428,54 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
) )
val fill = eventFill(drag.event.color, dark, soften) val fill = eventFill(drag.event.color, dark, soften)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// Read off the event rather than the held block, so grabbing either half // An end past midnight wraps rather than saturating, so a four-hour
// of an event that crosses midnight names the same hours. An end past // event dragged to 22:00 reads "22:0002:00" and not "22:0024:00".
// midnight wraps rather than saturating, so a four-hour event dragged to val endMin = if (drag.endMin > MINUTES_PER_DAY) {
// 22:00 reads "22:0002:00" and not "22:0024:00". drag.endMin % MINUTES_PER_DAY
val startMin = drag.eventStartMin.mod(MINUTES_PER_DAY) } else {
val rawEnd = startMin + drag.eventSpanMin drag.endMin
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd }
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}" + val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}" +
formatMinuteOfDay(endMin, use24Hour, locale) formatMinuteOfDay(endMin, use24Hour, locale)
// The tallest piece carries the range: on the smallest it would be
// clipped away, which is exactly the case when a short tail is held.
val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = origin,
sizePx = piece.sizePx,
fill = fill,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
}
}
}
/** One piece of a dragged block, floating over the calendar. */
@Composable
private fun DragCopy(
topLeftInRoot: Offset,
overlayOrigin: Offset,
sizePx: IntSize,
fill: Color,
shape: RoundedCornerShape,
lift: Float,
alpha: Float,
title: String,
label: String?,
) {
val density = LocalDensity.current
Box( Box(
modifier = Modifier modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware // Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout. // offset would mirror them across the screen in an RTL layout.
.absoluteOffset { .absoluteOffset {
IntOffset( IntOffset(
(topLeftInRoot.x - overlayOrigin.x).roundToInt(), (drag.topLeftInRoot.x - origin.x).roundToInt(),
(topLeftInRoot.y - overlayOrigin.y).roundToInt(), (drag.topLeftInRoot.y - origin.y).roundToInt(),
) )
} }
.size( .size(
width = with(density) { sizePx.width.toDp() }, width = with(density) { drag.sizePx.width.toDp() },
height = with(density) { sizePx.height.toDp() }, height = with(density) { drag.sizePx.height.toDp() },
) )
.padding(horizontal = BLOCK_OUTER_INSET) .padding(horizontal = 1.dp)
.graphicsLayer { .graphicsLayer {
scaleX = 1f + 0.02f * lift scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift shadowElevation = 8.dp.toPx() * lift
this.alpha = alpha alpha = copyAlpha
this.shape = shape shape = RoundedCornerShape(4.dp)
clip = false clip = false
} }
.background(fill, shape) .background(fill, RoundedCornerShape(4.dp))
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp), .padding(horizontal = 4.dp, vertical = 2.dp),
) { ) {
Column { Column {
val titleOverflow = eventTitleOverflow()
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = TextOverflow.Ellipsis,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
) )
if (label != null) {
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = TextOverflow.Ellipsis,
softWrap = titleOverflow.softWrap,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
) )
} }

View File

@@ -76,18 +76,6 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
.coerceAtLeast(fillHourHeight(viewportHeight)) .coerceAtLeast(fillHourHeight(viewportHeight))
} }
/**
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
* to the hour it landed in.
*
* [hourPx] must be the hour height the column is drawing at *now* — a tap
* detector that captured it when it was installed maps taps to a pre-pinch grid
* (#148). A zero or negative height (a viewport measured at nothing) has no grid
* to read, so it answers midnight rather than dividing by it.
*/
fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60
/** /**
* Shortest an event block may render, as a fraction of an hour. Blocks keep a * Shortest an event block may render, as a fraction of an hour. Blocks keep a
* floor so a 15-minute event stays tappable, but the floor scales with the hour * floor so a 15-minute event stays tappable, but the floor scales with the hour

View File

@@ -1,145 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
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.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.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.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlin.math.roundToInt
/**
* The viewport a view preview pretends to be: a phone's width by the height a
* calendar view gets under the top bar. Scaling every preview from one fixed
* height keeps them comparable — each shows the same slice of screen.
*/
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
/**
* Renders [content] at a full phone viewport and shrinks the finished layout
* into a box [height] tall, for the settings and onboarding choosers. Callers
* pass the *real* view composables, so a preview cannot drift from what it
* depicts.
*
* The [layout] below ignores the incoming constraints so the view measures at a
* phone viewport, and a [graphicsLayer] scale shrinks the result.
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
* overflows its constraints, leaving only the bottom-right corner in the clip.
*/
@Composable
internal fun ScaledViewPreview(
height: Dp,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
Box(
modifier = modifier
.clipToBounds()
.background(MaterialTheme.colorScheme.surface)
// A preview is a picture, not a control: swallow touches before the
// view'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 at a full phone viewport but report the scaled size, so the
// node occupies exactly what it draws.
.layout { measurable, _ ->
val fullWidth = screenWidth.roundToPx()
val fullHeight = PREVIEW_VIEWPORT_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()) { content() }
}
}
/**
* The stage every preview picker sets its preview on, cross-fading as [selected]
* changes. A preview draws the app's own surface, so a tonal plate gives it the
* edge it would otherwise lack on a chooser screen; the plate takes the width
* and outer radius of the grouped rows underneath so the two read as one stack.
*
* Only vertical spacing is the caller's to pass — the horizontal inset is the
* rows' own, and the alignment is the point.
*/
@Composable
internal fun <T> ViewPreviewFrame(
selected: T,
label: String,
modifier: Modifier = Modifier,
content: @Composable (T) -> Unit,
) {
val reduceMotion = rememberReduceMotion()
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset)
.clip(FRAME_SHAPE)
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(FRAME_INSET),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState = selected,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = label,
) { shown ->
// Each preview renders the real view at phone size and scales it
// down, so its own corners are square — the plate rounds them.
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
content(shown)
}
}
}
}
private val FRAME_INSET = 8.dp
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -1,39 +1,26 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
/** /**
* Top-bar pill that shows the current view and cycles to the next one on tap * Top-bar pill that shows the current view and cycles to the next one on tap
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]). * (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
*
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
* views (#150), so it drops straight into an app bar's `actions` slot without a
* wrapping condition — the same way [TodayAction] handles being turned off.
*
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
* `0.dp` outside a top app bar, where M3's actions padding is not there to
* cancel (#165).
*/ */
@Composable @Composable
fun ViewSwitcherPill( fun ViewSwitcherPill(
current: CalendarView, current: CalendarView,
cycle: List<CalendarView>,
onCycle: () -> Unit, onCycle: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
) { ) {
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
FilledTonalButton( FilledTonalButton(
onClick = onCycle, onClick = onCycle,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
modifier = modifier.padding(end = trailingInset), modifier = modifier,
) { ) {
Text(stringResource(current.labelRes)) Text(stringResource(current.labelRes))
} }

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.day package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
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
@@ -45,15 +46,16 @@ import androidx.compose.runtime.key
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
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -62,6 +64,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -77,12 +80,7 @@ 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.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.MAX_TIME_LINES
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -92,13 +90,8 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant import de.jeanlucmakiola.calendula.ui.common.startInstant
@@ -109,10 +102,8 @@ 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.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -125,9 +116,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock 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
@@ -142,7 +131,7 @@ private val ALL_DAY_ROW_HEIGHT = 24.dp
private val ALL_DAY_VERTICAL_PADDING = 6.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp
/** Total all-day strip height for the day (0 when there are no all-day events). */ /** Total all-day strip height for the day (0 when there are no all-day events). */
internal fun DayUiState.Success.allDayStripHeight(): Dp { private fun DayUiState.Success.allDayStripHeight(): Dp {
if (allDay.isEmpty()) return 0.dp if (allDay.isEmpty()) return 0.dp
val lanes = allDay.maxOf { it.lane } + 1 val lanes = allDay.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
@@ -172,9 +161,21 @@ fun DayScreen(
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) } initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// The all-day strip shares the app bar's scrolled colour so the whole top
// region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "day-top-section-color",
)
val isOnToday = when (val s = state) { val isOnToday = when (val s = state) {
is DayUiState.Success -> s.date == s.today is DayUiState.Success -> s.date == s.today
else -> true else -> true
@@ -232,19 +233,19 @@ fun DayScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
DayTopBar( DayTopBar(
date = date, date = date,
currentYear = currentYear, currentYear = currentYear,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate, onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
onToday = jumpToToday, onToday = jumpToToday,
scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -259,6 +260,7 @@ fun DayScreen(
DayContent( DayContent(
state = state, state = state,
slideDir = slideDir, slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext, onSwipeNext = goNext,
onSwipePrev = goPrev, onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
@@ -276,6 +278,7 @@ fun DayScreen(
private fun DayContent( private fun DayContent(
state: DayUiState, state: DayUiState,
slideDir: Int, slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit, onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit, onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
@@ -336,13 +339,14 @@ private fun DayContent(
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess( is DayUiState.Success -> DaySuccess(
state = s, state = s,
topSectionColor = topSectionColor,
scrollState = scrollState, scrollState = scrollState,
allDayHeight = allDayHeight, allDayHeight = allDayHeight,
dragController = dragController, dragController = dragController,
onEventClick = onEventClick, onEventClick = onEventClick,
onCreateAt = onCreateAt, onCreateAt = onCreateAt,
onDrop = { drop -> onDrop = { drop ->
move?.move( val took = move?.move(
MoveRequest( MoveRequest(
eventId = drop.event.eventId, eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -350,6 +354,9 @@ private fun DayContent(
target = MoveTarget.Start(drop.startInstant(zone)), target = MoveTarget.Start(drop.startInstant(zone)),
), ),
) )
// Refused, so nothing will land: let the copy go now
// rather than hold it out for a settle that never comes.
if (took != true) dragController.release()
}, },
) )
} }
@@ -359,8 +366,9 @@ private fun DayContent(
} }
@Composable @Composable
internal fun DaySuccess( private fun DaySuccess(
state: DayUiState.Success, state: DayUiState.Success,
topSectionColor: Color,
scrollState: ScrollState, scrollState: ScrollState,
allDayHeight: Dp, allDayHeight: Dp,
dragController: TimelineDragController, dragController: TimelineDragController,
@@ -377,10 +385,10 @@ internal fun DaySuccess(
onEventClick = onEventClick, onEventClick = onEventClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface), .background(topSectionColor),
) )
// Breathing room between the top section and the scrolling timeline // Breathing room between the (colour-shifting) top section and the
// below. // scrolling timeline below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Timeline( Timeline(
state = state, state = state,
@@ -400,25 +408,20 @@ private fun DayTopBar(
currentYear: Int, currentYear: Int,
selectedView: CalendarView, selectedView: CalendarView,
onCycleView: () -> Unit, onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean, showTodayButton: Boolean,
onToday: () -> Unit, onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale() val locale = currentLocale()
val (title, shortTitle) = remember(date, locale, currentYear) {
formatDayTitle(date, locale, currentYear) to
formatDayTitle(date, locale, currentYear, abbreviated = true)
}
TopAppBar( TopAppBar(
title = { title = {
CalendarTitleButton( CalendarTitleButton(
title = title, title = formatDayTitle(date, locale, currentYear),
currentDate = date, currentDate = date,
onJumpToDate = onJumpToDate, onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -439,17 +442,15 @@ private fun DayTopBar(
} }
ViewSwitcherPill( ViewSwitcherPill(
current = selectedView, current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView, onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
) )
}, },
// Deliberately flat: M3 lifts the bar to mark content scrolling under
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
scrollBehavior = scrollBehavior,
) )
} }
@@ -467,11 +468,7 @@ private fun AllDayStrip(
// Height is hoisted + animated so it resizes smoothly; padding sits // Height is hoisted + animated so it resizes smoothly; padding sits
// inside it so the content area is lanes * row height. // inside it so the content area is lanes * row height.
.height(height) .height(height)
.padding( .padding(vertical = ALL_DAY_VERTICAL_PADDING),
top = ALL_DAY_VERTICAL_PADDING,
bottom = ALL_DAY_VERTICAL_PADDING,
end = TIMELINE_CONTENT_END_INSET,
),
) { ) {
// Keep the gutter-width offset so the bars line up with the day column. // Keep the gutter-width offset so the bars line up with the day column.
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
@@ -513,21 +510,18 @@ private fun AllDayBar(
val fill = eventFill(event.color, dark, soften) val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.background(fill, EventChipShape) .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 },
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
val titleOverflow = eventTitleOverflow()
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = TextOverflow.Ellipsis,
softWrap = titleOverflow.softWrap,
color = eventInk(fill), color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
) )
} }
} }
@@ -573,7 +567,6 @@ private fun Timeline(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState) .verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it }, .onGloballyPositioned { dragController.geometry.viewport = it },
@@ -630,12 +623,6 @@ private fun DayColumnCard(
LaunchedEffect(blocks, dragController.settling) { LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks) dragController.noteGrid(date, blocks)
} }
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
// the height and the callback through state handles instead of capturing
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
Card( Card(
// Plain rectangular column — the soft corners come from the outer // Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges. // rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -656,10 +643,8 @@ private fun DayColumnCard(
// only fires on the column background. Snaps to the tapped hour. // only fires on the column background. Snaps to the tapped hour.
.pointerInput(date) { .pointerInput(date) {
detectTapGestures { offset -> detectTapGestures { offset ->
currentOnCreateAt.value( val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
date, onCreateAt(date, hour * 60)
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
} }
}, },
) { ) {
@@ -686,7 +671,6 @@ private fun DayColumnCard(
block = block, block = block,
dark = dark, dark = dark,
height = place.height, height = place.height,
width = place.width,
date = date, date = date,
dragController = dragController, dragController = dragController,
onClick = { onEventClick(block.event) }, onClick = { onEventClick(block.event) },
@@ -695,7 +679,7 @@ private fun DayColumnCard(
.offset(x = place.x, y = place.y) .offset(x = place.x, y = place.y)
.width(place.width) .width(place.width)
.height(place.height) .height(place.height)
.padding(horizontal = BLOCK_OUTER_INSET), .padding(horizontal = 1.dp),
) )
} }
} }
@@ -712,7 +696,6 @@ private fun EventBlock(
block: TimedBlock, block: TimedBlock,
dark: Boolean, dark: Boolean,
height: Dp, height: Dp,
width: Dp,
date: LocalDate, date: LocalDate,
dragController: TimelineDragController, dragController: TimelineDragController,
onClick: () -> Unit, onClick: () -> Unit,
@@ -734,61 +717,37 @@ private fun EventBlock(
// What's left for text once the 2.dp top/bottom padding is paid for. A block // What's left for text once the 2.dp top/bottom padding is paid for. A block
// that cannot afford both lines spends its space on the title, and one too // that cannot afford both lines spends its space on the title, and one too
// short even for that drops the title rather than serving a sliced one. // short even for that drops the title rather than serving a sliced one.
// Height alone decides: a duration threshold would keep hiding the time on a
// half-hour block the user has pinched open to three times the room it needs.
val available = height - 4.dp val available = height - 4.dp
val showTime = available >= titleLineHeight + timeLineHeight val showTime = block.endMin - block.startMin >= 45 &&
available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight val showTitle = available >= titleLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val titleMaxLines = if (showTime) 1 else 2
// The range only wraps out of a line the title has not claimed, which on a
// day column — wide enough for "09:3011:00" several times over — means it
// never does, until lanes cut the column down.
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime && spare >= timeLineHeight) {
blockTextLines(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
}
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften) val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event) // A block clipped at the top continues from the previous day: its top edge
// The drop takes this offset back off, so a tail clipped at midnight lands // is midnight, not the event's start, so dragging it would invent a time.
// where the event's own start belongs (#253). val draggable = moveAction != null && block.beginsOn(date, zone)
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
dragController.begin(block, clipOffset, pointer, blockRoot)
},
onMove = dragController::move, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = draggable && dragController.ghosts(block) val lifted = draggable && dragController.ghosts(block, date)
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
Box( Box(
modifier = modifier modifier = modifier
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
.then(dragModifier) .then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -796,19 +755,18 @@ private fun EventBlock(
) { ) {
Column { Column {
if (showTitle) { if (showTitle) {
BlockTitle( Text(
title = title, text = title,
maxLines = titleMaxLines, style = MaterialTheme.typography.labelMedium,
textWidth = textWidth, maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
) )
} }
if (showTime) { if (showTime) {
BlockTimeLabel( BlockTimeLabel(
label = timeLabel, label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
maxLines = timeMaxLines,
) )
} }
} }
@@ -823,12 +781,7 @@ private fun DayLoading() {
// Same scale resolution as the loaded timeline, so the skeleton's column // Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives. // doesn't resize the moment the real day arrives.
val totalHeight = scale.hourHeight(maxHeight) * 24 val totalHeight = scale.hourHeight(maxHeight) * 24
Row( Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
modifier = Modifier
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.verticalScroll(scrollState),
) {
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
Box( Box(
modifier = Modifier modifier = Modifier
@@ -844,16 +797,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)
/** [abbreviated] drops the weekday, leaving the date itself (#165). */ private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
private fun formatDayTitle(
date: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle( formatCalendarTitle(
date = 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),
locale = locale, locale = locale,
currentYear = currentYear, currentYear = currentYear,
skeleton = if (abbreviated) "dMMM" else "EEEdMMM", skeleton = "EEEdMMM",
) )

View File

@@ -1,63 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* A live, scaled-down Day view for the onboarding view chooser. Today's date is
* real; only the events are stand-ins.
*/
@Composable
internal fun DayViewPreview(
height: Dp,
modifier: Modifier = Modifier,
) {
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val state = remember(today, zone) { sampleDayState(today, zone) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range is noon — the same centring the live view does.
scrollState.scrollTo(scrollState.maxValue / 2)
}
ScaledViewPreview(height = height, modifier = modifier) {
DaySuccess(
state = state,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
onEventClick = {},
onCreateAt = { _, _ -> },
onDrop = {},
)
}
}
/** Sample day state, laid out through the same helpers the live view uses. */
private fun sampleDayState(today: LocalDate, zone: TimeZone): DayUiState.Success {
val days = listOf(today)
val events = sampleTimelineEvents(days, today, zone)
return DayUiState.Success(
date = today,
today = today,
allDay = layoutAllDay(events.filter { it.isAllDay }, days, zone),
timed = layoutDay(events, today, zone),
)
}

View File

@@ -9,6 +9,7 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme 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
@@ -97,9 +98,6 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
import de.jeanlucmakiola.calendula.domain.zoneDescriptor 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.FieldCopier
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -152,7 +150,6 @@ fun EventDetailScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) } var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
val copyField = rememberFieldCopier(snackbarHostState)
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages // Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
// an .ics in the cache and hands back a content Uri for the chooser. // an .ics in the cache and hands back a content Uri for the chooser.
@@ -303,7 +300,7 @@ fun EventDetailScreen(
reason = s.reason, reason = s.reason,
onRetry = viewModel::retry, onRetry = viewModel::retry,
) )
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier) is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
} }
} }
@@ -385,11 +382,7 @@ private fun DeleteEventDialog(
@Composable @Composable
private fun EventDetailContent( private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
state: EventDetailUiState.Success,
copyField: FieldCopier,
modifier: Modifier = Modifier,
) {
val detail = state.detail val detail = state.detail
val instance = detail.instance val instance = detail.instance
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
@@ -406,7 +399,6 @@ private fun EventDetailContent(
// event, so it's left implicit — only Free is worth surfacing. A // event, so it's left implicit — only Free is worth surfacing. A
// cancelled event strikes through its title. // cancelled event strikes through its title.
Row(verticalAlignment = Alignment.Top) { Row(verticalAlignment = Alignment.Top) {
val titleLabel = stringResource(R.string.event_detail_title)
Text( Text(
text = instance.title.ifBlank { stringResource(R.string.event_untitled) }, text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
@@ -416,17 +408,7 @@ private fun EventDetailContent(
} else { } else {
null null
}, },
modifier = Modifier modifier = Modifier.weight(1f),
.weight(1f)
.then(
// Nothing to copy off an untitled event — the placeholder
// isn't the event's own text.
if (instance.title.isNotBlank()) {
Modifier.copyOnLongPress(titleLabel, instance.title, copyField)
} else {
Modifier
},
),
) )
if (detail.availability == Availability.Free) { if (detail.availability == Availability.Free) {
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
@@ -534,11 +516,10 @@ private fun EventDetailContent(
// Location (conditional, tap → maps). // Location (conditional, tap → maps).
instance.location?.takeIf { it.isNotBlank() }?.let { location -> instance.location?.takeIf { it.isNotBlank() }?.let { location ->
val context = LocalContext.current val context = LocalContext.current
val locationLabel = stringResource(R.string.event_detail_location)
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( DetailCard(
icon = Icons.Default.Place, icon = Icons.Default.Place,
iconContentDescription = locationLabel, iconContentDescription = stringResource(R.string.event_detail_location),
) { ) {
Text( Text(
text = location, text = location,
@@ -546,12 +527,7 @@ private fun EventDetailContent(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.copyOnLongPress( .clickable { openInMaps(context, location) }
label = locationLabel,
text = location,
copy = copyField,
onTap = { openInMaps(context, location) },
)
.padding(vertical = 2.dp), .padding(vertical = 2.dp),
) )
} }
@@ -559,15 +535,10 @@ private fun EventDetailContent(
// Description (conditional). URLs are auto-linked. // Description (conditional). URLs are auto-linked.
detail.description?.takeIf { it.isNotBlank() }?.let { description -> detail.description?.takeIf { it.isNotBlank() }?.let { description ->
val descriptionLabel = stringResource(R.string.event_detail_description)
Spacer(Modifier.height(gap)) Spacer(Modifier.height(gap))
DetailCard( DetailCard(
icon = Icons.AutoMirrored.Filled.Notes, icon = Icons.AutoMirrored.Filled.Notes,
iconContentDescription = descriptionLabel, iconContentDescription = stringResource(R.string.event_detail_description),
// The gesture sits on the card so the icon and padding answer
// it too: every linkified URL owns the pointer over its own
// glyphs, which leaves the text itself a patchy target.
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
) { ) {
Text( Text(
text = linkifyUrls(description, MaterialTheme.colorScheme.primary), text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
@@ -640,14 +611,13 @@ private fun EventDetailContent(
private fun DetailCard( private fun DetailCard(
icon: ImageVector, icon: ImageVector,
iconContentDescription: String?, iconContentDescription: String?,
modifier: Modifier = Modifier,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Surface( Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
modifier = modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Row( Row(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),

View File

@@ -48,14 +48,9 @@ class EventDetailViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null) private val _target = MutableStateFlow<Target?>(null)
// Bumped by retry() and by re-opening the target already shown, to re-run // Bumped by retry() to re-run the load for the same target.
// the load without changing the target.
private val _reload = MutableStateFlow(0) private val _reload = MutableStateFlow(0)
// Last target whose content is already on screen; a re-read of it skips the
// Loading skeleton so the sheet doesn't blank out between two identical reads.
private var loadedTarget: Target? = null
private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle) private val _deleteState = MutableStateFlow<DeleteUiState>(DeleteUiState.Idle)
val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow() val deleteState: StateFlow<DeleteUiState> = _deleteState.asStateFlow()
@@ -63,14 +58,11 @@ class EventDetailViewModel @Inject constructor(
combine(_target, _reload) { target, _ -> target } combine(_target, _reload) { target, _ -> target }
.flatMapLatest { target -> .flatMapLatest { target ->
if (target == null) { if (target == null) {
loadedTarget = null
flowOf<EventDetailUiState>(EventDetailUiState.Loading) flowOf<EventDetailUiState>(EventDetailUiState.Loading)
} else { } else {
flow { flow {
if (loadedTarget != target) emit(EventDetailUiState.Loading) emit(EventDetailUiState.Loading)
val loaded = loadDetail(target) emit(loadDetail(target))
loadedTarget = target.takeIf { loaded is EventDetailUiState.Success }
emit(loaded)
} }
} }
} }
@@ -86,15 +78,9 @@ class EventDetailViewModel @Inject constructor(
* the occurrence's own times (from `CalendarContract.Instances`); they * the occurrence's own times (from `CalendarContract.Instances`); they
* override the series DTSTART/DTEND so recurring events show the correct * override the series DTSTART/DTEND so recurring events show the correct
* date instead of the first occurrence. * date instead of the first occurrence.
*
* Re-opening the *same* occurrence always re-reads it. The view model
* outlives the sheet, and a `StateFlow` conflates an identical value away,
* so assigning the target alone would leave an edit that changed no time —
* adding a description, say (#196) — showing the state from before the save.
*/ */
fun open(eventId: Long, beginMillis: Long, endMillis: Long) { fun open(eventId: Long, beginMillis: Long, endMillis: Long) {
val target = Target(eventId, beginMillis, endMillis) _target.value = Target(eventId, beginMillis, endMillis)
if (_target.value == target) _reload.value += 1 else _target.value = target
} }
/** Re-run the current load after a failure. */ /** Re-run the current load after a failure. */

View File

@@ -64,7 +64,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -265,16 +264,13 @@ fun EventEditScreen(
viewModel.reset() viewModel.reset()
onSaved() onSaved()
} }
// A failed save leaves the form looking unchanged, so the snackbar is
// the only sign anything happened — long rather than the default
// flash (Codeberg #234: it read as "nothing happens at all").
SaveUiState.Failed -> { SaveUiState.Failed -> {
viewModel.consumeSaveResult() viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long) snackbarHostState.showSnackbar(saveFailedMessage)
} }
SaveUiState.NeedsPermission -> { SaveUiState.NeedsPermission -> {
viewModel.consumeSaveResult() viewModel.consumeSaveResult()
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long) snackbarHostState.showSnackbar(writeDeniedMessage)
} }
// AwaitingScope/AwaitingConflict/Gone render as dialogs below. // AwaitingScope/AwaitingConflict/Gone render as dialogs below.
else -> Unit else -> Unit
@@ -794,9 +790,6 @@ private fun EventEditContent(
value = form.location, value = form.location,
onValueChange = viewModel::setLocation, onValueChange = viewModel::setLocation,
placeholder = stringResource(R.string.event_detail_location), placeholder = stringResource(R.string.event_detail_location),
// A location is as often a meeting URL as an address,
// and "Zoom.us/j/123" reads wrong (#146).
capitalization = KeyboardCapitalization.None,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
@@ -2226,9 +2219,7 @@ private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Lo
/** /**
* 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
* the app share one input style. Sentence-case by default, as floret-kit is; * the app share one input style.
* a field holding an identifier rather than prose passes
* [KeyboardCapitalization.None].
*/ */
@Composable @Composable
private fun InlineField( private fun InlineField(
@@ -2240,7 +2231,6 @@ private fun InlineField(
minLines: Int = 1, minLines: Int = 1,
enabled: Boolean = true, enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text, keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
modifier: Modifier = Modifier modifier: Modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
@@ -2255,7 +2245,7 @@ private fun InlineField(
minLines = minLines, minLines = minLines,
enabled = enabled, enabled = enabled,
keyboardType = keyboardType, keyboardType = keyboardType,
capitalization = capitalization, capitalization = KeyboardCapitalization.None,
) )
} }

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.edit package de.jeanlucmakiola.calendula.ui.edit
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -57,8 +56,6 @@ import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant import kotlin.time.Instant
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "EventEdit"
/** /**
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources * Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the * want different reminder handling (#49), and differ in whether they own the
@@ -737,12 +734,7 @@ class EventEditViewModel @Inject constructor(
} else { } else {
when (scope) { when (scope) {
RecurringWriteScope.ThisEvent -> RecurringWriteScope.ThisEvent ->
repository.updateOccurrence( repository.updateOccurrence(target.eventId, target.beginMillis, form)
target.eventId,
target.beginMillis,
target.original,
form,
)
RecurringWriteScope.ThisAndFollowing -> RecurringWriteScope.ThisAndFollowing ->
repository.updateEventFromOccurrence( repository.updateEventFromOccurrence(
eventId = target.eventId, eventId = target.eventId,
@@ -759,15 +751,7 @@ class EventEditViewModel @Inject constructor(
throw e throw e
} catch (e: SecurityException) { } catch (e: SecurityException) {
SaveUiState.NeedsPermission SaveUiState.NeedsPermission
} catch (e: NoSuchEventException) {
// The event or occurrence is already gone: the same answer the
// pre-check gives, and better than a bare "couldn't save".
SaveUiState.Gone
} catch (e: Exception) { } catch (e: Exception) {
// The user only gets a generic snackbar, so without this a failed
// write leaves nothing to report (Codeberg #234). Scope and event
// id only — never the form's content.
Log.w(TAG, "Save failed (scope=$scope, eventId=${target?.eventId})", e)
SaveUiState.Failed SaveUiState.Failed
} }
} }

View File

@@ -23,7 +23,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PriorityHigh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -94,11 +93,15 @@ fun ImportScreen(
} }
// Hoisted target calendar so the always-visible top-bar Import action can // Hoisted target calendar so the always-visible top-bar Import action can
// read it without the user scrolling to a bottom button (see // read it without the user scrolling to a bottom button. Defaults to the
// [defaultImportTarget] for the choice). Re-defaults when the "many" list // first *local* calendar — the first row the picker shows ("Your calendars"
// first arrives (keyed on it), then holds the pick. // group leads) — so the pre-selection lines up with the top of the list;
// falls back to the first calendar if there are no local ones. Re-defaults
// when the "many" list first arrives (keyed on it), then holds the pick.
val many = state as? ImportUiState.Many val many = state as? ImportUiState.Many
val defaultTarget = many?.let { defaultImportTarget(it.calendars, it.fileCalendarName) } val defaultTarget = many?.calendars?.let { cals ->
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
}
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) } var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
Scaffold( Scaffold(
@@ -220,10 +223,6 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
) )
} }
// Part of the file never reached the calendar, so the screen doesn't get to
// read as a plain success.
val stopped = state.summary.notAttempted > 0
Column( Column(
Modifier.fillMaxSize().padding(24.dp), Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -237,43 +236,22 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
scaleY = badgeScale.value scaleY = badgeScale.value
} }
.clip(CircleShape) .clip(CircleShape)
.background( .background(MaterialTheme.colorScheme.primaryContainer),
if (stopped) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.primaryContainer
},
),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
if (stopped) Icons.Default.PriorityHigh else Icons.Default.Check, Icons.Default.Check,
contentDescription = null, contentDescription = null,
tint = if (stopped) { tint = MaterialTheme.colorScheme.onPrimaryContainer,
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onPrimaryContainer
},
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) )
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text( Text(
stringResource( stringResource(R.string.import_done_title),
if (stopped) R.string.import_done_stopped_title else R.string.import_done_title,
),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
if (state.summary.failed > 0) {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.import_done_failed_note),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
if (state.summary.skippedDuplicate > 0) { if (state.summary.skippedDuplicate > 0) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
@@ -283,21 +261,6 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
// The one note that isn't about individual events: the counts below cover
// the part of the file that was tried, so say how much never was.
if (stopped) {
Spacer(Modifier.height(8.dp))
Text(
pluralStringResource(
R.plurals.import_done_stopped,
state.summary.notAttempted,
state.summary.notAttempted,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
@@ -327,19 +290,6 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
onContainer = MaterialTheme.colorScheme.onSurfaceVariant, onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
if (state.summary.failed > 0) {
ImportStatCard(
count = state.summary.failed,
label = stringResource(R.string.import_done_failed_label),
contentDescription = pluralStringResource(
R.plurals.import_done_failed,
state.summary.failed,
state.summary.failed,
),
container = MaterialTheme.colorScheme.errorContainer,
onContainer = MaterialTheme.colorScheme.onErrorContainer,
)
}
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
Button( Button(
@@ -351,7 +301,7 @@ private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
} }
} }
/** A big-number tonal tile summarising one import outcome (added / skipped / failed). */ /** A big-number tonal tile summarising one import outcome (added / skipped). */
@Composable @Composable
private fun RowScope.ImportStatCard( private fun RowScope.ImportStatCard(
count: Int, count: Int,
@@ -393,9 +343,6 @@ private fun WarningText(warning: IcsParseWarning) {
IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start) IcsParseWarning.EventWithoutStartSkipped -> stringResource(R.string.import_warning_no_start)
IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees) IcsParseWarning.AttendeesIgnored -> stringResource(R.string.import_warning_attendees)
IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone) IcsParseWarning.UnknownTimezone -> stringResource(R.string.import_warning_timezone)
IcsParseWarning.TasksImportedAsEvents -> stringResource(R.string.import_warning_tasks)
IcsParseWarning.RecurrenceRuleRepaired ->
stringResource(R.string.import_warning_recurrence_repaired)
} }
Text( Text(
text = text, text = text,

View File

@@ -43,12 +43,6 @@ sealed interface ImportUiState {
val events: List<ParsedIcsEvent>, val events: List<ParsedIcsEvent>,
val warnings: Set<IcsParseWarning>, val warnings: Set<IcsParseWarning>,
val calendars: List<CalendarSource>, val calendars: List<CalendarSource>,
/**
* The calendar the file says its events came from, when every one of
* them names the same — used to preselect a matching target (see
* [fileCalendarName]).
*/
val fileCalendarName: String? = null,
) : ImportUiState ) : ImportUiState
data class Done(val summary: IcsImportSummary) : ImportUiState data class Done(val summary: IcsImportSummary) : ImportUiState
@@ -98,7 +92,6 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings, warnings = parsed.warnings,
calendars = repository.calendars().first() calendars = repository.calendars().first()
.filter { it.isEventTarget }, .filter { it.isEventTarget },
fileCalendarName = fileCalendarName(parsed.events),
) )
} }
} }
@@ -120,33 +113,3 @@ class ImportViewModel @Inject constructor(
} }
} }
} }
/**
* The one calendar [events] all came from, or null.
*
* Every event has to agree, unnamed ones included: a foreign file where a single
* `VEVENT` carries a `CATEGORIES` tag and the rest carry nothing names no
* calendar — it just has one tagged event, and letting it pick the target would
* land the whole file wherever that tag happens to match.
*/
internal fun fileCalendarName(events: List<ParsedIcsEvent>): String? =
events.map { it.calendarName }.distinct().singleOrNull()
/**
* The target calendar to preselect for a bulk import.
*
* A calendar named like the one the file came from wins — restoring "Birthdays"
* onto the Birthdays calendar is what the user means. Otherwise the first
* *local* calendar, which is the first row the picker shows ("Your calendars"
* leads), so the pre-selection lines up with the top of the list; failing that
* the first calendar of any kind.
*/
internal fun defaultImportTarget(
calendars: List<CalendarSource>,
fileCalendarName: String?,
): Long? {
val named = fileCalendarName?.let { name ->
calendars.firstOrNull { it.displayName.equals(name, ignoreCase = true) }
}
return (named ?: calendars.firstOrNull { it.isLocal } ?: calendars.firstOrNull())?.id
}

View File

@@ -13,8 +13,6 @@ import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlin.math.abs import kotlin.math.abs
@@ -35,8 +33,6 @@ class MonthRowGeometry(
val isRtl: Boolean, val isRtl: Boolean,
/** What this row currently draws in [lane] of column `col`, or null. */ /** What this row currently draws in [lane] of column `col`, or null. */
val chipAt: (col: Int, lane: Int) -> EventInstance?, val chipAt: (col: Int, lane: Int) -> EventInstance?,
/** The column the chip in [lane] of column `col` is drawn from. */
val chipStart: (col: Int, lane: Int) -> Int,
) { ) {
/** Root top-left of the chip seated in [lane] of column [col]. */ /** Root top-left of the chip seated in [lane] of column [col]. */
fun seat(col: Int, lane: Int): Offset? { fun seat(col: Int, lane: Int): Offset? {
@@ -51,43 +47,21 @@ class MonthRowGeometry(
* the day isn't in this week, or the chip went into the day's "+N" overflow. * the day isn't in this week, or the chip went into the day's "+N" overflow.
*/ */
fun seatOf(event: EventInstance, date: LocalDate): Offset? { fun seatOf(event: EventInstance, date: LocalDate): Offset? {
val (col, lane) = chipSeat(days, laneCount, chipAt, chipStart, event, date) ?: return null val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
val lane = (0 until laneCount).firstOrNull { lane ->
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
} ?: return null
return seat(col, lane) return seat(col, lane)
} }
} }
/**
* The column and lane a row draws [event]'s chip in, given it covers [date] —
* null when the row seats no such chip. The column is the *left end* of the bar
* within this row, not [date]'s own: a bar spanning several days is drawn once,
* from its first column, so an event dropped onto the second day it covers is
* seated a column (or more) to the left of where it landed (#253).
*/
internal fun chipSeat(
days: List<LocalDate>,
laneCount: Int,
chipAt: (col: Int, lane: Int) -> EventInstance?,
chipStart: (col: Int, lane: Int) -> Int,
event: EventInstance,
date: LocalDate,
): Pair<Int, Int>? {
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
val lane = (0 until laneCount).firstOrNull { lane ->
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
} ?: return null
return chipStart(col, lane) to lane
}
/** /**
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id * Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
* alone will do: re-read instances get new instance ids, and a single-occurrence * alone will do: re-read instances get new instance ids, and a single-occurrence
* move writes a new event id — the title survives both. A *blank* title is no * move writes a new event id — the title survives both.
* evidence at all, though: every untitled event carries one, so matching on it
* made any two of them the same event (#253).
*/ */
internal fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean = private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
chip.eventId == moved.eventId || chip.eventId == moved.eventId || chip.title == moved.title
(moved.title.isNotBlank() && chip.title == moved.title)
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */ /** A chip in flight, in root coordinates so it can be drawn in an overlay. */
data class MonthChipDrag( data class MonthChipDrag(
@@ -160,16 +134,6 @@ class MonthDragController {
*/ */
private var undoable: MonthChipDrag? = null private var undoable: MonthChipDrag? = null
/**
* The abandoned-move counter, read at the drop so the mark can't be a
* composition behind the tick it is compared against.
*/
var abandonedTicks: StateFlow<Int>? = null
/** What [abandonedTicks] stood at when the settling chip was let go. */
var settledAbandonedMark: Int = 0
private set
private var event: EventInstance? = null private var event: EventInstance? = null
private var grabDate: LocalDate? = null private var grabDate: LocalDate? = null
private var grab = Offset.Zero private var grab = Offset.Zero
@@ -258,7 +222,6 @@ class MonthDragController {
liftedInstanceId = last.event.instanceId liftedInstanceId = last.event.instanceId
settledGhost = true settledGhost = true
settledInRoot = null settledInRoot = null
settledAbandonedMark = abandonedTicks?.value ?: 0
return true return true
} }
@@ -279,7 +242,6 @@ class MonthDragController {
liftedInstanceId = landed.event.instanceId liftedInstanceId = landed.event.instanceId
settledGhost = true settledGhost = true
undoable = settling undoable = settling
settledAbandonedMark = abandonedTicks?.value ?: 0
return MonthChipDrop(landed.event, landed.grabDate, target) return MonthChipDrop(landed.event, landed.grabDate, target)
} }
@@ -336,10 +298,6 @@ class MonthDragController {
} }
@Composable @Composable
fun rememberMonthDragController(): MonthDragController { fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
val controller = remember { MonthDragController() }
controller.abandonedTicks = LocalEventMove.current?.abandoned
return controller
}
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null } val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }

View File

@@ -19,8 +19,6 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -91,12 +89,10 @@ import androidx.compose.ui.geometry.Offset
import kotlin.math.roundToInt import kotlin.math.roundToInt
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.MoveTarget import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.MoveRequest import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.moveInFlight import de.jeanlucmakiola.calendula.ui.common.moveInFlight
import de.jeanlucmakiola.calendula.ui.common.abandonedMoves
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
@@ -114,7 +110,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -134,7 +130,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle 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.AppBarSpacing
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
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.TodayAction
@@ -143,12 +138,9 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
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.declinedDecoration
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.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
@@ -213,6 +205,7 @@ fun MonthScreen(
derivedStateOf { if (dimCompleted) nowState.value else null } derivedStateOf { if (dimCompleted) nowState.value else null }
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -298,14 +291,10 @@ fun MonthScreen(
// carries the year instead of repeating it two lines further down. Dense has // carries the year instead of repeating it two lines further down. Dense has
// no header to defer to, so it keeps the full title. // no header to defer to, so it keeps the full title.
val locale = currentLocale() val locale = currentLocale()
val (topBarTitle, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) { val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
if (viewStyle == MonthViewStyle.Continuous) { titleMonth.year.toString()
val year = titleMonth.year.toString()
year to year
} else { } else {
formatMonthTitle(titleMonth, locale, currentYear = today.year) to formatMonthTitle(titleMonth, locale, currentYear = today.year)
formatMonthTitle(titleMonth, locale, currentYear = today.year, abbreviated = true)
}
} }
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
@@ -390,20 +379,19 @@ fun MonthScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
MonthTopBar( MonthTopBar(
title = topBarTitle, title = topBarTitle,
shortTitle = topBarShortTitle,
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1), titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate, onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
onToday = jumpToToday, onToday = jumpToToday,
scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -451,7 +439,6 @@ fun MonthScreen(
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onRetry = jumpToToday, onRetry = jumpToToday,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} else if (viewStyle == MonthViewStyle.Split) { } else if (viewStyle == MonthViewStyle.Split) {
SplitMonthContent( SplitMonthContent(
@@ -476,7 +463,6 @@ fun MonthScreen(
onSwipePrev = goPrev, onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} }
} }
@@ -513,14 +499,11 @@ private fun MonthDragOverlay(controller: MonthDragController) {
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) } val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>() val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
val seat = controller.settledInRoot val seat = controller.settledInRoot
val abandoned = abandonedMoves() LaunchedEffect(controller.settling, moveInFlight, seat) {
LaunchedEffect(controller.settling, moveInFlight, seat, abandoned) {
val landed = controller.settling val landed = controller.settling
if (landed == null || moveInFlight) return@LaunchedEffect if (landed == null || moveInFlight) return@LaunchedEffect
if (seat == null) { if (seat == null) {
// Past the mark this drop was let go on, it was abandoned and no delay(MONTH_SETTLE_TIMEOUT_MILLIS)
// seat is ever coming.
if (abandoned == controller.settledAbandonedMark) delay(MONTH_SETTLE_TIMEOUT_MILLIS)
} else { } else {
if (!gliding) glide.snapTo(landed.topLeftInRoot) if (!gliding) glide.snapTo(landed.topLeftInRoot)
gliding = true gliding = true
@@ -574,7 +557,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
scaleY = 1f + 0.04f * lift scaleY = 1f + 0.04f * lift
shadowElevation = 8.dp.toPx() * lift shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha alpha = copyAlpha
shape = EventChipShape shape = RoundedCornerShape(4.dp)
clip = false clip = false
}, },
) )
@@ -590,7 +573,6 @@ private fun MonthContent(
onSwipePrev: () -> Unit, onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) { ) {
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec() val fadeSpec = rememberCalendarFadeSpec()
@@ -617,7 +599,6 @@ private fun MonthContent(
state = s, state = s,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} }
} }
@@ -636,7 +617,6 @@ private fun ContinuousMonthContent(
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onRetry: () -> Unit, onRetry: () -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) { ) {
when (state) { when (state) {
// The scrolling styles get their own skeleton rather than the paged // The scrolling styles get their own skeleton rather than the paged
@@ -651,7 +631,6 @@ private fun ContinuousMonthContent(
listState = listState, listState = listState,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} else { } else {
ContinuousMonthGrid( ContinuousMonthGrid(
@@ -659,7 +638,6 @@ private fun ContinuousMonthContent(
listState = listState, listState = listState,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} }
} }
@@ -669,16 +647,15 @@ private fun ContinuousMonthContent(
@Composable @Composable
private fun MonthTopBar( private fun MonthTopBar(
title: String, title: String,
shortTitle: String,
titleDate: LocalDate, titleDate: LocalDate,
selectedView: CalendarView, selectedView: CalendarView,
onCycleView: () -> Unit, onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean, showTodayButton: Boolean,
onToday: () -> Unit, onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
TopAppBar( TopAppBar(
title = { title = {
@@ -686,7 +663,6 @@ private fun MonthTopBar(
title = title, title = title,
currentDate = titleDate, currentDate = titleDate,
onJumpToDate = onJumpToDate, onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -707,17 +683,11 @@ private fun MonthTopBar(
} }
ViewSwitcherPill( ViewSwitcherPill(
current = selectedView, current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView, onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
) )
}, },
// Deliberately flat: M3 lifts the bar to mark content scrolling under scrollBehavior = scrollBehavior,
// it, but here the bar meets the header on the same surface and the
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
) )
} }
@@ -732,7 +702,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
) { ) {
// Reserve the gutter so the weekday labels stay over their day columns. // Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER)) if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
@@ -766,8 +736,7 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
/** Width of the split style's selected-day outline. */ /** Width of the split style's selected-day outline. */
private val SPLIT_SELECTION_STROKE = 1.5.dp private val SPLIT_SELECTION_STROKE = 1.5.dp
/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */ private const val MAX_EVENT_ROWS = 3
internal const val MAX_EVENT_ROWS = 3
/** /**
* Row height in the continuous grid. The paged grid divides the viewport between * Row height in the continuous grid. The paged grid divides the viewport between
@@ -785,25 +754,21 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
*/ */
private val CONTINUOUS_MONTH_GAP = 20.dp private val CONTINUOUS_MONTH_GAP = 20.dp
/** Gap between the weekday header and the seamless stream's first week row. */
private val DENSE_HEADER_GAP = 4.dp
@Composable @Composable
internal fun MonthGrid( internal fun MonthGrid(
state: MonthUiState.Success, state: MonthUiState.Success,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */ /** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
selected: LocalDate? = null, selected: LocalDate? = null,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// Match the weekday header's inset so day cells sit under their // Match the weekday header's 8dp inset so day cells sit under their
// labels, and so the week-number gutter's centre lines up with the // labels, and so the week-number gutter's centre lines up with the
// top bar's hamburger (4dp bar inset + 24dp half icon button). // top bar's hamburger (4dp bar inset + 24dp half icon button).
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
val month = state.month val month = state.month
@@ -814,7 +779,6 @@ internal fun MonthGrid(
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
selected = selected, selected = selected,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -845,7 +809,6 @@ internal fun ContinuousMonthGrid(
listState: LazyListState, listState: LazyListState,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val monthCount = remember { continuousMonthCount() } val monthCount = remember { continuousMonthCount() }
@@ -873,7 +836,6 @@ internal fun ContinuousMonthGrid(
today = state.today, today = state.today,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
) )
} }
} }
@@ -893,13 +855,12 @@ private fun ContinuousMonthBlock(
today: LocalDate, today: LocalDate,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
) { ) {
val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) } val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset) .padding(horizontal = 8.dp)
.padding(bottom = CONTINUOUS_MONTH_GAP), .padding(bottom = CONTINUOUS_MONTH_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
@@ -916,7 +877,6 @@ private fun ContinuousMonthBlock(
blankOutside = true, blankOutside = true,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(CONTINUOUS_ROW_HEIGHT), .height(CONTINUOUS_ROW_HEIGHT),
@@ -975,7 +935,6 @@ internal fun DenseMonthGrid(
listState: LazyListState, listState: LazyListState,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
@@ -983,15 +942,10 @@ internal fun DenseMonthGrid(
state = listState, state = listState,
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
// The gap under the weekday header is a real margin, not content .padding(horizontal = 8.dp),
// padding: a lazy list scrolls its rows *through* the before-content
// padding, so scrolling to a week landed its row that far down with
// the tail of the previous one showing above it (#191).
.padding(horizontal = AppBarSpacing.Inset)
.padding(top = DENSE_HEADER_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
// Bottom inset clears the FAB stack so the last row stays tappable. // Bottom inset clears the FAB stack so the last row stays tappable.
contentPadding = PaddingValues(bottom = 96.dp), contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp),
) { ) {
items(count = weekCount, key = { it }) { index -> items(count = weekCount, key = { it }) { index ->
val week = state.weeksByIndex[index] val week = state.weeksByIndex[index]
@@ -1006,7 +960,6 @@ internal fun DenseMonthGrid(
inMonth = { true }, inMonth = { true },
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick,
labelMonthOnFirst = true, labelMonthOnFirst = true,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1234,9 +1187,6 @@ private fun SplitMonthBody(
onSelectDay(it) onSelectDay(it)
onSetExpanded(false) onSetExpanded(false)
}, },
// A tapped chip is asking for that event, not for its day, so it
// opens the detail from here too rather than collapsing (#187).
onEventClick = onEventClick,
onCollapse = { onSetExpanded(false) }, onCollapse = { onSetExpanded(false) },
) )
} else { } else {
@@ -1337,7 +1287,6 @@ private fun SplitMonthExpanded(
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
swipeModifier: Modifier, swipeModifier: Modifier,
onPickDay: (LocalDate) -> Unit, onPickDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
onCollapse: () -> Unit, onCollapse: () -> Unit,
) { ) {
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
@@ -1360,7 +1309,6 @@ private fun SplitMonthExpanded(
state = s, state = s,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onPickDay, onOpenDay = onPickDay,
onEventClick = onEventClick,
selected = sel, selected = sel,
) )
} }
@@ -1454,7 +1402,7 @@ internal fun SplitMonthGrid(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
state.weeks.forEach { week -> state.weeks.forEach { week ->
@@ -1819,7 +1767,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = AppBarSpacing.Inset) .padding(horizontal = 8.dp)
.clipToBounds(), .clipToBounds(),
) { ) {
if (!dense) { if (!dense) {
@@ -1841,7 +1789,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
) )
} else { } else {
Spacer(Modifier.height(DENSE_HEADER_GAP)) Spacer(Modifier.height(4.dp))
} }
// More rows than a viewport holds; the clip takes the overflow. // More rows than a viewport holds; the clip takes the overflow.
repeat(6) { repeat(6) {
@@ -1889,7 +1837,6 @@ private fun MonthWeekRow(
inMonth: (LocalDate) -> Boolean, inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
blankOutside: Boolean = false, blankOutside: Boolean = false,
labelMonthOnFirst: Boolean = false, labelMonthOnFirst: Boolean = false,
@@ -1938,7 +1885,6 @@ private fun MonthWeekRow(
laneCount = MAX_EVENT_ROWS, laneCount = MAX_EVENT_ROWS,
isRtl = isRtl, isRtl = isRtl,
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) }, chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
chipStart = { col, lane -> week.chipStartCol(col, lane) },
), ),
) )
} }
@@ -2189,17 +2135,11 @@ private fun MonthWeekRow(
} }
} }
// Tap layer: a tap on a chip opens that event, anything else opens the // Tap layer: in month view a tap on any day opens that day. Padded and
// day (#187). The chips take no pointer input of their own — this
// layer covers them — so the lane under the finger is resolved
// geometrically, exactly as the drag pickup above does it; the down
// position is read on the initial pass, which consumes nothing and so
// leaves both the click and a pickup in flight untouched. Padded and
// clipped to the background pill so the ripple matches it. A blanked // clipped to the background pill so the ripple matches it. A blanked
// cell isn't part of this month, so it takes no taps either. // cell isn't part of this month, so it takes no taps either.
val downY = remember(week.days.size) { FloatArray(week.days.size) { NO_DOWN_Y } }
Row(Modifier.matchParentSize()) { Row(Modifier.matchParentSize()) {
week.days.forEachIndexed { col, d -> week.days.forEach { d ->
if (blankOutside && !inMonth(d)) { if (blankOutside && !inMonth(d)) {
Spacer(Modifier.weight(1f).fillMaxHeight()) Spacer(Modifier.weight(1f).fillMaxHeight())
} else { } else {
@@ -2207,33 +2147,9 @@ private fun MonthWeekRow(
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.pointerInput(col) {
awaitEachGesture {
downY[col] = awaitFirstDown(
requireUnconsumed = false,
pass = PointerEventPass.Initial,
).position.y
}
}
.padding(horizontal = CELL_GAP, vertical = 1.dp) .padding(horizontal = CELL_GAP, vertical = 1.dp)
.clip(CELL_SHAPE) .clip(CELL_SHAPE)
.clickable { .clickable { onOpenDay(d) },
// Cleared on read: a click with no fresh down
// (TalkBack, D-pad) would otherwise resolve the
// previous tap's position and reopen its chip.
val cellY = downY[col]
downY[col] = NO_DOWN_Y
val chip = week.chipAtCellY(
col = col,
cellY = cellY,
bandTopInCell = bandTopInCell(
cellCoordinates,
bandCoordinates,
),
rowHeightPx = rowHeightPx,
)
if (chip != null) onEventClick(chip) else onOpenDay(d)
},
) )
} }
} }
@@ -2242,43 +2158,6 @@ private fun MonthWeekRow(
} }
} }
/**
* How far the event band sits below the top of the row's day-column box, or null
* while either is unmeasured. Read off the live coordinates rather than summed
* from the padding constants, so it can't drift from what the row actually drew.
*/
private fun bandTopInCell(
cell: Array<LayoutCoordinates?>,
band: Array<LayoutCoordinates?>,
): Float? {
val cellTop = cell[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null
return bandTop - cellTop
}
/**
* Stand-in [cellY] for "no touch down recorded", which resolves to no chip.
*/
private const val NO_DOWN_Y = Float.NEGATIVE_INFINITY
/**
* The chip at [cellY] in column [col], where [cellY] is measured from the top of
* the row's day-column box. Null for a tap above the band (the day number), on an
* empty lane, or on the overflow dots — all of which mean "open the day", the
* dots included: their point is that the day holds more than fits.
*/
internal fun MonthWeek.chipAtCellY(
col: Int,
cellY: Float,
bandTopInCell: Float?,
rowHeightPx: Float,
): EventInstance? {
if (bandTopInCell == null || rowHeightPx <= 0f) return null
val bandY = cellY - bandTopInCell
if (bandY < 0f) return null
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS)
}
/** /**
* The row-level pickup for month chips: resolves which chip the press landed on * The row-level pickup for month chips: resolves which chip the press landed on
* from the geometry the row just laid out, and abandons the gesture on empty * from the geometry the row just laid out, and abandons the gesture on empty
@@ -2293,7 +2172,7 @@ private fun monthChipDragModifier(
rowHeightPx: Float, rowHeightPx: Float,
isRtl: Boolean, isRtl: Boolean,
): Modifier = rememberDragSurface( ): Modifier = rememberDragSurface(
enabled = moveScope?.dragEnabled == true && controller != null, enabled = moveScope != null && controller != null,
key = week.days.first(), key = week.days.first(),
onPickUp = { local, pointerInRoot, nodeInRoot, size -> onPickUp = { local, pointerInRoot, nodeInRoot, size ->
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y
@@ -2330,7 +2209,7 @@ private fun monthChipDragModifier(
// How many columns the finger crossed — grabbing the middle of a // How many columns the finger crossed — grabbing the middle of a
// multi-day bar shifts by what it travelled, not to where it landed. // multi-day bar shifts by what it travelled, not to where it landed.
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt() val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
moveScope?.move( val took = moveScope?.move(
MoveRequest( MoveRequest(
eventId = drop.event.eventId, eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -2338,6 +2217,9 @@ private fun monthChipDragModifier(
target = MoveTarget.ByDays(delta), target = MoveTarget.ByDays(delta),
), ),
) )
// Refused, so nothing will land: let the copy go now rather than
// hold the chip out for a settle that can never arrive.
if (took != true) controller?.release()
} }
}, },
onCancel = { controller?.cancel() }, onCancel = { controller?.cancel() },
@@ -2453,7 +2335,12 @@ private fun MonthBar(
val lifted = monthDrag?.liftedInstanceId == event.instanceId || val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
monthDrag?.isSettledChip(event, days) == true monthDrag?.isSettledChip(event, days) == true
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
val shape = monthBarShape(continuesLeft, continuesRight) val shape = RoundedCornerShape(
topStart = if (continuesLeft) 0.dp else 4.dp,
bottomStart = if (continuesLeft) 0.dp else 4.dp,
topEnd = if (continuesRight) 0.dp else 4.dp,
bottomEnd = if (continuesRight) 0.dp else 4.dp,
)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
@@ -2465,15 +2352,12 @@ private fun MonthBar(
}, },
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
val titleOverflow = eventTitleOverflow()
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = TextOverflow.Ellipsis,
softWrap = titleOverflow.softWrap,
color = eventInk(fill), color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
) )
} }
} }
@@ -2529,7 +2413,7 @@ private fun MonthGridLoading() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
repeat(6) { repeat(6) {
@@ -2555,16 +2439,10 @@ private fun MonthGridLoading() {
} }
} }
/** [abbreviated] swaps the full month name for its three-letter form (#165). */ private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
private fun formatMonthTitle(
ym: YearMonth,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String =
formatCalendarTitle( formatCalendarTitle(
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1), date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
locale = locale, locale = locale,
currentYear = currentYear, currentYear = currentYear,
skeleton = if (abbreviated) "LLL" else "LLLL", skeleton = "LLLL",
) )

View File

@@ -1,31 +1,54 @@
package de.jeanlucmakiola.calendula.ui.month 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
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 kotlinx.datetime.YearMonth import kotlinx.datetime.YearMonth
import kotlinx.datetime.atTime
import kotlinx.datetime.plus import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.math.roundToInt
/** /**
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings * A live, scaled-down Month view in a given [MonthViewStyle], for the settings
* and onboarding choosers. * 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 * The month, today's position and the week start are all real; only the events
* are stand-ins. See [ScaledViewPreview] for how the real grid composables get * are stand-ins, since the settings screen has no business querying the
* measured at phone size and shrunk into the card. * provider for a thumbnail.
*/ */
@Composable @Composable
internal fun MonthStylePreview( internal fun MonthStylePreview(
@@ -34,18 +57,56 @@ internal fun MonthStylePreview(
height: Dp, height: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val scale = height.value / VIRTUAL_HEIGHT.value
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date } val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) } val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
ScaledViewPreview(height = height, modifier = modifier) { 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) WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
when (style) { when (style) {
MonthViewStyle.Paged -> MonthGrid( MonthViewStyle.Paged -> MonthGrid(
state = sample.month, state = sample.month,
showWeekNumbers = false, showWeekNumbers = false,
onOpenDay = {}, onOpenDay = {},
onEventClick = {},
) )
MonthViewStyle.Continuous -> ContinuousMonthGrid( MonthViewStyle.Continuous -> ContinuousMonthGrid(
state = sample.continuous, state = sample.continuous,
@@ -56,19 +117,18 @@ internal fun MonthStylePreview(
), ),
showWeekNumbers = false, showWeekNumbers = false,
onOpenDay = {}, onOpenDay = {},
onEventClick = {},
) )
MonthViewStyle.Dense -> DenseMonthGrid( MonthViewStyle.Dense -> DenseMonthGrid(
state = sample.continuous, state = sample.continuous,
listState = rememberLazyListState( listState = rememberLazyListState(
// Start a week above today's, so the preview shows a stream // Start a week above today's, so the preview shows a
// running past the viewport rather than one beginning at its // stream running past the viewport rather than one
// top edge. // beginning at its top edge.
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1, initialFirstVisibleItemIndex =
weekIndexOf(today, weekStart) - 1,
), ),
showWeekNumbers = false, showWeekNumbers = false,
onOpenDay = {}, onOpenDay = {},
onEventClick = {},
) )
MonthViewStyle.Split -> { MonthViewStyle.Split -> {
SplitMonthGrid( SplitMonthGrid(
@@ -91,7 +151,14 @@ internal fun MonthStylePreview(
} }
} }
} }
}
/**
* 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 val SPLIT_PANE_HEIGHT = 170.dp
private class SampleMonth( private class SampleMonth(
@@ -103,6 +170,9 @@ private class SampleMonth(
* A month's worth of stand-in events, laid out through the same * A month's worth of stand-in events, laid out through the same
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview * [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
* exercises the real span, lane and overflow logic rather than approximating it. * 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( private fun sampleMonthState(
today: LocalDate, today: LocalDate,
@@ -111,7 +181,7 @@ private fun sampleMonthState(
): SampleMonth { ): SampleMonth {
val ym = YearMonth(today.year, today.month) val ym = YearMonth(today.year, today.month)
val first = LocalDate(ym.year, ym.month, 1) val first = LocalDate(ym.year, ym.month, 1)
val events = sampleMonthEvents(first, today, zone) val events = sampleEvents(first, today, zone)
val weeks = layoutMonthWeeks(ym, weekStart, events, zone) val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
val month = MonthUiState.Success( val month = MonthUiState.Success(
@@ -143,3 +213,72 @@ private fun sampleMonthState(
) )
return SampleMonth(month, continuous) 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",
)

View File

@@ -84,16 +84,6 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index) return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
} }
/**
* The column the chip drawn in lane [lane] of column [col] begins in. A bar is
* drawn once, from its own first column, so a chip seated by [chipAt] on the
* second day it covers belongs a column (or more) to the left (#253). Answers
* [col] for a single-day chip, and for an empty slot no one should be asking
* about.
*/
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
/** /**
* The events on [day] that [laneEvents] had no lane left for — its exact * The events on [day] that [laneEvents] had no lane left for — its exact
* complement, in the same bars-then-pills order. Returned as events rather than * complement, in the same bars-then-pills order. Returned as events rather than

View File

@@ -12,7 +12,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -418,11 +417,8 @@ internal fun layoutCalendarWeek(
zone: TimeZone, zone: TimeZone,
): MonthWeek { ): MonthWeek {
val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } } val weekEvents = instances.filter { ev -> days.any { ev.coversDay(it, zone) } }
// A bar is decided by the *event*, not by how much of it this row holds: one
// crossing midnight into the next week row covers a single day in each, and
// counting per row drew it as two unrelated pills (#253).
val (bars, singles) = weekEvents.partition { ev -> val (bars, singles) = weekEvents.partition { ev ->
ev.isAllDay || ev.spansMultipleDays(zone) ev.isAllDay || days.count { ev.coversDay(it, zone) } > 1
} }
val spans = layoutAllDay(bars, days, zone).map { s -> val spans = layoutAllDay(bars, days, zone).map { s ->
MonthSpan( MonthSpan(

View File

@@ -1,100 +0,0 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
/**
* Wizard step shown only when everything you write to is a device-only calendar
* (#163): the events exist nowhere but this phone, so losing it loses them.
*
* The primary action picks a folder and switches automatic backup on in one go.
* Everything it sets is a normal Backup setting, adjustable afterwards.
*/
@Composable
internal fun BackupStep(
onEnable: (Uri) -> Unit,
onSkip: () -> Unit,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) {
val pickFolder = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
) { uri ->
// Cancelling the picker decides nothing and leaves the step open.
uri?.let(onEnable)
}
OnboardingScaffold(
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.CloudOff) },
actions = {
Button(
onClick = { runCatching { pickFolder.launch(null) } },
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_backup_enable_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.onboarding_backup_skip_button))
}
},
) {
Text(
text = stringResource(R.string.onboarding_backup_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_backup_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.lg))
BenefitRow(
icon = Icons.Filled.FolderOpen,
title = stringResource(R.string.onboarding_backup_benefit_folder_title),
body = stringResource(R.string.onboarding_backup_benefit_folder_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.Schedule,
title = stringResource(R.string.onboarding_backup_benefit_daily_title),
body = stringResource(R.string.onboarding_backup_benefit_daily_body),
)
}
}

View File

@@ -1,61 +0,0 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
/**
* The wizard's closing screen (#163): the flow ends on a full screen saying so
* rather than dropping into the calendar mid-gesture, and the brand mark wears
* the check that the last step earned.
*/
@Composable
internal fun DoneStep(
onFinished: () -> Unit,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) {
OnboardingScaffold(
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.lg,
hero = { BrandHero(HeroBadge.Done) },
actions = {
Button(
onClick = onFinished,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_done_button),
style = MaterialTheme.typography.titleMedium,
)
}
},
) {
Text(
text = stringResource(R.string.onboarding_done_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.onboarding_done_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -1,58 +0,0 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.IntOffset
/**
* The M3 Expressive spatial spring the wizard's steps travel on. Read in
* composable scope so it can be captured by `AnimatedContent`'s non-composable
* transitionSpec lambda.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun rememberOnboardingSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/**
* Moving between wizard steps (#163): the steps sit side by side and the whole
* width swipes across, position on a spring so the arriving step settles with a
* little give rather than stopping dead.
*
* No cross-fade, unlike the calendar's shared-axis paging: two steps have
* nothing in common, so fading one through the other smears. Both are opaque and
* travel in lockstep, showing one screen pushing the other off.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back.
* @param spec spatial spec, typically [rememberOnboardingSlideSpec].
* @param fadeSpec effects spec used for the whole transition under reduced
* motion.
* @param reduceMotion when true, drop the movement and cross-fade alone.
*/
internal fun onboardingSlideTransition(
slideDir: Int,
spec: FiniteAnimationSpec<IntOffset>,
fadeSpec: FiniteAnimationSpec<Float>,
reduceMotion: Boolean,
): ContentTransform {
if (reduceMotion) {
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
}
val dir = if (slideDir == 0) 1 else slideDir
return ContentTransform(
targetContentEnter = slideInHorizontally(spec) { w -> dir * w },
initialContentExit = slideOutHorizontally(spec) { w -> -dir * w },
// Clipping to the animating container would shear both steps against the
// viewport edge as they pass; there is no size change here to contain.
sizeTransform = SizeTransform(clip = false),
)
}

View File

@@ -1,116 +0,0 @@
package de.jeanlucmakiola.calendula.ui.onboarding
/** One screen of the first-launch wizard (#163), in the order they are shown. */
enum class OnboardingStep {
/** Required: nothing works without the calendar grant. */
Permission,
/** Whether Calendula delivers reminder notifications itself. */
Reminders,
/** Only when nothing you write to is synced anywhere — offers a backup. */
Backup,
/** Which view the app opens on, chosen from a live preview. */
View,
/** How the Month view lays itself out, chosen from a live preview. */
MonthStyle,
/** Only on an install that has calendars the device is hiding (#75). */
Visibility,
/** The closing screen: everything is answered and the app is ready. */
Done,
}
/**
* Which steps this install owes and where it has got to. [steps] is the whole
* flow — completed steps included — so the counter doesn't renumber as steps
* are finished; [current] is null once nothing is left and the app opens.
*/
data class OnboardingPlan(
val steps: List<OnboardingStep>,
val current: OnboardingStep?,
) {
/** 1-based position of [current] in [steps], or 0 when the flow is done. */
val index: Int get() = steps.indexOf(current) + 1
val total: Int get() = steps.size
/** A one-step flow is not a wizard: there is nothing to count down. */
val showsProgress: Boolean get() = total > 1
/** The step before [current], or null at the start (and once finished). */
val previous: OnboardingStep? get() = steps.getOrNull(index - 2).takeIf { current != null }
/**
* Whether the wizard can step back. Everything is re-askable except the
* calendar grant, which belongs to the system — once given there is nothing
* for a back press to return to.
*/
val canGoBack: Boolean get() = previous != null && previous != OnboardingStep.Permission
}
/**
* Work out the flow from what is stored.
*
* The optional steps belong to installs still working through their first run.
* Before the grant that can only be guessed, so one that has not answered the
* reminder step either counts as fresh — the same condition
* [de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs.armOnboardingWizard]
* commits to storage a moment later, and clears again on the closing screen so
* a later re-grant is only the permission step.
*
* [backupApplies] is null until the calendar list can be read, i.e. for the
* whole permission step; the backup step is assumed to apply until proven
* otherwise, so the flow can only ever get shorter — never sprout a step the
* counter had not accounted for.
*
* The month-style step is *not* conditional on Month being the chosen view:
* Month is reachable from the drawer whatever opens first, and a step that came
* and went as the view is picked would move the counter under the user on the
* step right before it.
*
* [visibilityArmed] carries the one-time notice that the device is hiding some
* calendars (#75) — the only optional step an *existing* install can be given,
* which is why it is not gated on `fresh`, and on its own it earns no closing
* screen.
*/
fun onboardingPlan(
hasPermission: Boolean,
remindersDone: Boolean,
wizardArmed: Boolean,
backupDone: Boolean,
viewDone: Boolean,
monthStyleDone: Boolean,
backupApplies: Boolean?,
visibilityArmed: Boolean = false,
visibilityDone: Boolean = false,
doneShown: Boolean = false,
): OnboardingPlan {
val fresh = wizardArmed || (!hasPermission && !remindersDone)
val steps = buildList {
if (!hasPermission || fresh) add(OnboardingStep.Permission)
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
if (fresh) {
add(OnboardingStep.View)
add(OnboardingStep.MonthStyle)
}
if (visibilityArmed) add(OnboardingStep.Visibility)
if (fresh) add(OnboardingStep.Done)
}
val current = steps.firstOrNull { step ->
when (step) {
OnboardingStep.Permission -> !hasPermission
OnboardingStep.Reminders -> !remindersDone
OnboardingStep.Backup -> !backupDone
OnboardingStep.View -> !viewDone
OnboardingStep.MonthStyle -> !monthStyleDone
OnboardingStep.Visibility -> !visibilityDone
OnboardingStep.Done -> !doneShown
}
}
return OnboardingPlan(steps, current)
}

View File

@@ -1,118 +0,0 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.activity.compose.BackHandler
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.OnboardingProgress
/**
* Renders the wizard's current [step] (#163). Each step commits its own answer
* through [viewModel], which then hands the host the next one.
*/
@Composable
fun OnboardingSteps(
step: OnboardingStep,
plan: OnboardingPlan,
viewModel: OnboardingViewModel,
onPermissionGranted: () -> Unit,
modifier: Modifier = Modifier,
) {
// Collected for every step, not just the two that draw a preview: starting
// it when the picker composes would leave that step blank as it slides in.
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
// Coerced because the outgoing half of a transition may be a step the live
// plan has since dropped — the backup step goes once the calendars say it
// does not apply — and a "Step 0 of 3" flash is worse than a stale number.
val index = (plan.steps.indexOf(step) + 1).coerceAtLeast(1)
val progress: (@Composable () -> Unit)? = if (!plan.showsProgress) {
null
} else {
{
OnboardingProgress(
step = index,
total = plan.total,
label = stringResource(R.string.onboarding_step_counter, index, plan.total),
)
}
}
// The system back gesture does the same thing as the button, and stays off
// on the first step so back still leaves the app.
BackHandler(enabled = plan.canGoBack) { viewModel.goBack() }
val navigationIcon: (@Composable () -> Unit)? = if (!plan.canGoBack) {
null
} else {
{
IconButton(onClick = viewModel::goBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.onboarding_back),
)
}
}
}
when (step) {
OnboardingStep.Permission -> PermissionScreen(
onGranted = onPermissionGranted,
modifier = modifier,
progress = progress,
)
OnboardingStep.Reminders -> ReminderStep(
onFinished = viewModel::finishReminders,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
OnboardingStep.Backup -> BackupStep(
onEnable = viewModel::enableAutoBackup,
onSkip = viewModel::skipBackup,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
// The choice is null only until DataStore's first emission; rendering
// nothing for that frame beats a preview built on the wrong defaults.
OnboardingStep.View -> choice?.let {
ViewStep(
choice = it,
onSelect = viewModel::setDefaultView,
onFinished = viewModel::finishView,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
}
OnboardingStep.MonthStyle -> choice?.let {
MonthStyleStep(
choice = it,
onSelect = viewModel::setMonthViewStyle,
onFinished = viewModel::finishMonthStyle,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
}
OnboardingStep.Visibility -> VisibilityStep(
onFinished = viewModel::finishVisibility,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
OnboardingStep.Done -> DoneStep(
onFinished = viewModel::finishOnboarding,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
)
}
}

View File

@@ -1,321 +0,0 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the first-launch wizard (#163): decides which steps this install owes
* (see [onboardingPlan]) and commits each answer.
*
* [plan] is null until the first stored emission, so the host renders nothing
* for that frame rather than flashing a step the user has already answered.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class OnboardingViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val prefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
private val repository: CalendarRepository,
private val scanner: ReminderScanner,
private val visibilityReconciler: CalendarVisibilityReconciler,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
/** Null until the host reports it — assuming either way would flash a screen. */
private val permissionReported = MutableStateFlow<Boolean?>(null)
/**
* The grant as the rest of the flow sees it: published only once the wizard
* has been armed for it, since arming is a stored write and the frames
* before it lands would collapse the plan to its one-step form.
*/
private val hasPermission: StateFlow<Boolean?> = permissionReported
.mapLatest { granted ->
if (granted == true) armWizardIfFresh()
granted
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
/**
* Whether the backup step applies: something of yours is worth exporting and
* nothing you write to is synced anywhere. Null while the calendar list
* cannot be read: every frame before the grant, and any read that fails —
* an empty list is also how a null cursor arrives, and guessing false there
* would drop the step for the whole session.
*/
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
if (granted != true) {
flowOf(null)
} else {
repository.calendars()
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
.catch { emit(null) }
.flowOn(io)
}
}
private val flags: Flow<OnboardingFlags> = combine(
prefs.reminderOnboardingDone,
prefs.onboardingWizardArmed,
prefs.onboardingBackupDone,
prefs.onboardingViewDone,
prefs.onboardingMonthStyleDone,
::OnboardingFlags,
)
/** The closing steps' own flags, and whether the notice applies at all. */
private val closingFlags: Flow<ClosingFlags> = combine(
calendarPrefs.visibilityNoticePending.map { it == true },
prefs.onboardingVisibilityDone,
prefs.onboardingDoneShown,
::ClosingFlags,
)
val plan: StateFlow<OnboardingPlan?> =
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
granted?.let {
onboardingPlan(
hasPermission = it,
remindersDone = stored.remindersDone,
wizardArmed = stored.wizardArmed,
backupDone = stored.backupDone,
viewDone = stored.viewDone,
monthStyleDone = stored.monthStyleDone,
backupApplies = backup,
visibilityArmed = closing.visibilityArmed,
visibilityDone = closing.visibilityDone,
doneShown = closing.doneShown,
)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/** The current view preferences, for the view step's initial selection. */
val viewChoice: StateFlow<ViewChoice?> = combine(
prefs.defaultView,
prefs.monthViewStyle,
prefs.weekStart,
::ViewChoice,
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
fun setHasPermission(granted: Boolean) {
permissionReported.value = granted
}
/**
* Arm the wizard for an install that has yet to answer the reminder step,
* whatever route the grant took. Gating this on the in-app grant would
* strand anyone who granted on Android's own settings screen and came back
* to a restarted process, leaving them the reminder step alone.
*/
private suspend fun armWizardIfFresh() {
if (!prefs.reminderOnboardingDone.first()) prefs.armOnboardingWizard()
}
/**
* Record the in-app grant. The launch scan ran before it and bailed out
* without arming anything, so the first alarm would otherwise wait on the
* daily worker.
*/
fun onPermissionGranted() {
scanner.scanInBackground()
}
/** Close the reminder step, recording whether notifications stay on. */
fun finishReminders(remindersEnabled: Boolean) {
viewModelScope.launch {
prefs.setRemindersEnabled(remindersEnabled)
prefs.setReminderOnboardingDone()
// Nothing else re-arms the scan: turning reminders off cancels the
// alarm (#75).
scanner.scan()
}
}
/**
* Re-ask the step before the current one, by clearing its answer. The plan
* derives the position from those answers, so un-setting one *is* the back
* navigation. The calendar grant belongs to the system and cannot be
* returned to, which [OnboardingPlan.canGoBack] already refuses.
*/
fun goBack() {
viewModelScope.launch {
when (plan.value?.previous) {
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false)
OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false)
OnboardingStep.Done -> prefs.setOnboardingDoneShown(false)
OnboardingStep.Permission, null -> Unit
}
}
}
/** Close the backup step without setting anything up. */
fun skipBackup() {
viewModelScope.launch { prefs.setOnboardingBackupDone() }
}
/**
* Turn automatic backup on, writing to the folder the user just picked
* (taking a durable write grant so background runs can keep writing), and
* close the step.
*/
fun enableAutoBackup(folder: Uri) {
viewModelScope.launch {
runCatching {
context.contentResolver.takePersistableUriPermission(
folder,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
prefs.setAutoBackupFolderUri(folder.toString())
prefs.setAutoBackupEnabled(true)
BackupScheduler.apply(
context = context,
enabled = true,
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
hasFolder = true,
)
// Immediate feedback: the first backup lands while onboarding runs.
BackupScheduler.runNow(context)
prefs.setOnboardingBackupDone()
}
}
fun setDefaultView(view: CalendarView) {
viewModelScope.launch { prefs.setDefaultView(view) }
}
fun setMonthViewStyle(style: MonthViewStyle) {
viewModelScope.launch { prefs.setMonthViewStyle(style) }
}
/** Close a picker step — the choice itself is saved as it is made. */
fun finishView() {
viewModelScope.launch { prefs.setOnboardingViewDone() }
}
fun finishMonthStyle() {
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
}
/** Acknowledge the calendar-visibility notice (#75). */
fun finishVisibility() {
viewModelScope.launch {
prefs.setOnboardingVisibilityDone()
// Retiring the notice drops its step, so it waits until nothing
// follows — the count must not fall on the way to a later screen.
if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice()
}
}
/** Close the wizard from its last screen. */
fun finishOnboarding() {
viewModelScope.launch {
prefs.finishOnboardingWizard()
retireVisibilityNotice()
}
}
/**
* Put the one-time visibility notice away for good — nothing else clears it
* now that the wizard owns it, and left standing it would keep its step in
* every later plan.
*/
private suspend fun retireVisibilityNotice() {
if (calendarPrefs.visibilityNoticePending.first() == true) {
calendarPrefs.setVisibilityNoticePending(false)
}
}
/**
* Fold the retired app-local calendar visibility into the device's own, and
* arm the notice if this install has anything to announce. Runs whenever the
* app comes up holding the permission, however it was granted — one taken on
* Android's app-settings screen never reaches the permission step's callback.
*/
fun reconcileCalendarVisibility() {
viewModelScope.launch { visibilityReconciler.run() }
}
}
/** The stored answers the plan is derived from. */
private data class OnboardingFlags(
val remindersDone: Boolean,
val wizardArmed: Boolean,
val backupDone: Boolean,
val viewDone: Boolean,
val monthStyleDone: Boolean,
)
/** The tail of the flow: the visibility notice and the closing screen. */
private data class ClosingFlags(
val visibilityArmed: Boolean,
val visibilityDone: Boolean,
val doneShown: Boolean,
)
/** What the view step starts from, and renders its previews with. */
data class ViewChoice(
val defaultView: CalendarView,
val monthViewStyle: MonthViewStyle,
val weekStart: WeekStartPref,
)
/**
* True when this device holds events that live nowhere else: at least one local
* calendar worth exporting, and no writable synced calendar to carry them.
* A read-only subscription is not a backup, so it does not count.
*/
private fun List<CalendarSource>.backupApplies(): Boolean {
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
val syncedTarget = any { !it.isLocal && it.canModifyContents }
return exportable && !syncedTarget
}

View File

@@ -1,209 +0,0 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
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.calendula.ui.week.WeekViewPreview
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.locale.currentLocale
/**
* Wizard step: which view the app opens on (#163). Built like the Settings
* picker it mirrors — a live preview over connected grouped rows. Selecting
* applies immediately and leaves the step open; the preview is the confirmation.
*/
@Composable
internal fun ViewStep(
choice: ViewChoice,
onSelect: (CalendarView) -> Unit,
onFinished: () -> Unit,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) {
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
StepPickerScaffold(
title = stringResource(R.string.onboarding_view_title),
onFinished = onFinished,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
) {
StepPreview(selected = choice.defaultView) { view ->
when (view) {
CalendarView.Month -> MonthStylePreview(
style = choice.monthViewStyle,
weekStart = weekStart,
height = PREVIEW_HEIGHT,
)
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
}
}
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
val isSelected = view == choice.defaultView
GroupedRow(
title = stringResource(view.labelRes),
position = positionOf(index, IMPLEMENTED_VIEWS.size),
selected = isSelected,
leading = {
Icon(
imageVector = view.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(view) },
)
}
}
}
/**
* Wizard step: how the Month view lays itself out (#163). The same shape as
* [ViewStep] and as the Settings chooser this duplicates, down to the selected
* style's own blurb sitting under its preview rather than on every row.
*/
@Composable
internal fun MonthStyleStep(
choice: ViewChoice,
onSelect: (MonthViewStyle) -> Unit,
onFinished: () -> Unit,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) {
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
val options = MonthViewStyle.entries
StepPickerScaffold(
title = stringResource(R.string.onboarding_month_style_title),
onFinished = onFinished,
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
) {
StepPreview(selected = choice.monthViewStyle) { style ->
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
}
PickerDescription(stringResource(choice.monthViewStyle.descriptionRes))
options.forEachIndexed { index, style ->
val isSelected = style == choice.monthViewStyle
GroupedRow(
title = stringResource(style.labelRes),
position = positionOf(index, options.size),
selected = isSelected,
trailing = if (isSelected) {
{ SelectedCheck() }
} else {
null
},
onClick = { onSelect(style) },
)
}
}
}
/**
* The shell both picker steps share: the wizard chrome around a title and a
* full-bleed column, since grouped rows carry their own inset.
*/
@Composable
private fun StepPickerScaffold(
title: String,
onFinished: () -> Unit,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
body: @Composable ColumnScope.() -> Unit,
) {
OnboardingScaffold(
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.xs,
contentPadding = 0.dp,
// The preview gets the room it needs; Continue scrolls in under the
// options rather than pinning to the bottom.
scrollingActions = true,
hero = {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset),
)
},
actions = {
Button(
onClick = onFinished,
// Full-bleed step: the button takes the grouped rows' own inset
// and ends up as wide as the options above it.
modifier = Modifier
.padding(horizontal = GroupedListInset)
.fillMaxWidth()
.height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_view_continue_button),
style = MaterialTheme.typography.titleMedium,
)
}
},
body = body,
)
}
/**
* The live preview, framed like the picker's. The wider gap below keeps the rows
* reading as a group of their own rather than as the preview's caption.
*/
@Composable
private fun <T> StepPreview(selected: T, content: @Composable (T) -> Unit) {
ViewPreviewFrame(
selected = selected,
label = "step-preview",
modifier = Modifier.padding(top = OnboardingSpace.xs, bottom = OnboardingSpace.md),
content = content,
)
}
/** The same size the Settings chooser gives it. */
private val PREVIEW_HEIGHT: Dp = 280.dp

View File

@@ -1,65 +0,0 @@
package de.jeanlucmakiola.calendula.ui.onboarding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
/**
* Wizard step for the one-time notice that Calendula now follows the device's
* own per-calendar visibility and some of yours are switched off (#75, #163).
*
* Nothing to decide here — the app does not switch those calendars back on,
* since that would un-hide them everywhere else too.
*/
@Composable
internal fun VisibilityStep(
onFinished: () -> Unit,
modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) {
OnboardingScaffold(
modifier = modifier,
progress = progress,
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.VisibilityOff) },
actions = {
Button(
onClick = onFinished,
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.onboarding_visibility_button),
style = MaterialTheme.typography.titleMedium,
)
}
},
) {
Text(
text = stringResource(R.string.calendars_visibility_notice_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.calendars_visibility_notice_message),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.permission
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -17,25 +16,20 @@ 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.vector.ImageVector
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.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
/** What, if anything, the brand mark is wearing in its corner. */
internal enum class HeroBadge { Denied, Done }
/** /**
* The app's adaptive launcher mark, reconstructed as a large branded squircle * The app's adaptive launcher mark, reconstructed as a large branded squircle
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies * the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
* the shell; the mark stays app-local so each sibling keeps its own identity). * the shell; the mark stays app-local so each sibling keeps its own identity).
* A [badge] overlays the corner where the screen has a verdict to give: blocked, * A lock badge overlays the corner when permission has been [denied].
* or finished.
*/ */
@Composable @Composable
internal fun BrandHero(badge: HeroBadge? = null) { internal fun BrandHero(denied: Boolean) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -49,56 +43,24 @@ internal fun BrandHero(badge: HeroBadge? = null) {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
} }
if (badge != null) { if (denied) {
val container = when (badge) { // A small lock badge sits over the corner to signal "blocked".
HeroBadge.Denied -> MaterialTheme.colorScheme.errorContainer
HeroBadge.Done -> MaterialTheme.colorScheme.primaryContainer
}
val onContainer = when (badge) {
HeroBadge.Denied -> MaterialTheme.colorScheme.onErrorContainer
HeroBadge.Done -> MaterialTheme.colorScheme.onPrimaryContainer
}
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.offset(x = 10.dp, y = 10.dp) .offset(x = 10.dp, y = 10.dp)
.size(44.dp) .size(44.dp)
.clip(CircleShape) .clip(CircleShape)
.background(container), .background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Icon( Icon(
imageVector = when (badge) { imageVector = Icons.Filled.Lock,
HeroBadge.Denied -> Icons.Filled.Lock
HeroBadge.Done -> Icons.Filled.Check
},
contentDescription = null, contentDescription = null,
tint = onContainer, tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
) )
} }
} }
} }
} }
/**
* A single [icon] in the brand squircle the same silhouette as [BrandHero],
* for the wizard steps that stand for a feature rather than for the app itself.
*/
@Composable
internal fun IconHero(icon: ImageVector) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
}

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.permission
import de.jeanlucmakiola.floret.components.BenefitRow import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
@@ -10,15 +10,19 @@ import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -28,11 +32,13 @@ import androidx.compose.material3.TextButton
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.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
@@ -46,7 +52,6 @@ private val CALENDAR_PERMISSIONS = arrayOf(
fun PermissionScreen( fun PermissionScreen(
onGranted: () -> Unit, onGranted: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
viewModel: PermissionViewModel = hiltViewModel(), viewModel: PermissionViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -71,7 +76,6 @@ fun PermissionScreen(
when (state) { when (state) {
is PermissionUiState.Rationale -> RationaleContent( is PermissionUiState.Rationale -> RationaleContent(
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) }, onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
progress = progress,
modifier = modifier, modifier = modifier,
) )
is PermissionUiState.Denied -> DeniedContent( is PermissionUiState.Denied -> DeniedContent(
@@ -79,7 +83,6 @@ fun PermissionScreen(
viewModel.onRetry() viewModel.onRetry()
launcher.launch(CALENDAR_PERMISSIONS) launcher.launch(CALENDAR_PERMISSIONS)
}, },
progress = progress,
modifier = modifier, modifier = modifier,
) )
is PermissionUiState.Granted -> { is PermissionUiState.Granted -> {
@@ -91,14 +94,11 @@ fun PermissionScreen(
@Composable @Composable
private fun RationaleContent( private fun RationaleContent(
onRequest: () -> Unit, onRequest: () -> Unit,
progress: (@Composable () -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
OnboardingScaffold( OnboardingScaffold(
modifier = modifier, modifier = modifier,
progress = progress, hero = { BrandHero(denied = false) },
topSpacing = OnboardingSpace.lg,
hero = { BrandHero() },
actions = { actions = {
Button( Button(
onClick = onRequest, onClick = onRequest,
@@ -116,8 +116,16 @@ private fun RationaleContent(
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) )
} }
PrivacyFootnote()
}, },
) { ) {
Text(
text = stringResource(R.string.app_name).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(OnboardingSpace.xs))
Text( Text(
text = stringResource(R.string.permission_rationale_title), text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
@@ -131,8 +139,14 @@ private fun RationaleContent(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(OnboardingSpace.lg)) Spacer(Modifier.height(OnboardingSpace.xl))
BenefitRow(
icon = Icons.Filled.Lock,
title = stringResource(R.string.permission_benefit_private_title),
body = stringResource(R.string.permission_benefit_private_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow( BenefitRow(
icon = Icons.Filled.CalendarMonth, icon = Icons.Filled.CalendarMonth,
title = stringResource(R.string.permission_benefit_sync_title), title = stringResource(R.string.permission_benefit_sync_title),
@@ -140,9 +154,9 @@ private fun RationaleContent(
) )
Spacer(Modifier.height(OnboardingSpace.sm)) Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow( BenefitRow(
icon = Icons.Filled.Lock, icon = Icons.Filled.VisibilityOff,
title = stringResource(R.string.permission_benefit_private_title), title = stringResource(R.string.permission_benefit_privacy_title),
body = stringResource(R.string.permission_benefit_private_body), body = stringResource(R.string.permission_benefit_privacy_body),
) )
} }
} }
@@ -150,14 +164,12 @@ private fun RationaleContent(
@Composable @Composable
private fun DeniedContent( private fun DeniedContent(
onRetry: () -> Unit, onRetry: () -> Unit,
progress: (@Composable () -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
OnboardingScaffold( OnboardingScaffold(
modifier = modifier, modifier = modifier,
progress = progress, hero = { BrandHero(denied = true) },
hero = { BrandHero(HeroBadge.Denied) },
actions = { actions = {
Button( Button(
onClick = { onClick = {
@@ -196,3 +208,25 @@ private fun DeniedContent(
) )
} }
} }
@Composable
private fun PrivacyFootnote() {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(R.string.permission_privacy_footnote),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.permission
sealed interface PermissionUiState { sealed interface PermissionUiState {
data object Rationale : PermissionUiState data object Rationale : PermissionUiState

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.permission
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.calendula.ui.onboarding package de.jeanlucmakiola.calendula.ui.permission
import de.jeanlucmakiola.floret.components.BenefitRow import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold import de.jeanlucmakiola.floret.components.OnboardingScaffold
@@ -8,25 +8,34 @@ import android.Manifest
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
/** /**
* Wizard step after the calendar grant (v1.4): explains that * One-time onboarding step after the calendar grant (v1.4): explains that
* Calendula delivers reminder notifications itself, warns about duplicates * Calendula delivers reminder notifications itself, warns about duplicates
* when a second calendar app has notifications on, and requests * when a second calendar app has notifications on, and requests
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29). * `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
@@ -36,11 +45,9 @@ import de.jeanlucmakiola.calendula.R
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off. * the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
*/ */
@Composable @Composable
fun ReminderStep( fun ReminderOnboardingScreen(
onFinished: (remindersEnabled: Boolean) -> Unit, onFinished: (remindersEnabled: Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: (@Composable () -> Unit)? = null,
navigationIcon: (@Composable () -> Unit)? = null,
) { ) {
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission(),
@@ -48,10 +55,7 @@ fun ReminderStep(
OnboardingScaffold( OnboardingScaffold(
modifier = modifier, modifier = modifier,
progress = progress, hero = { BellHero() },
navigationIcon = navigationIcon,
topSpacing = OnboardingSpace.lg,
hero = { IconHero(Icons.Filled.NotificationsActive) },
actions = { actions = {
Button( Button(
onClick = { onClick = {
@@ -76,6 +80,13 @@ fun ReminderStep(
} }
}, },
) { ) {
Text(
text = stringResource(R.string.app_name).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(OnboardingSpace.xs))
Text( Text(
text = stringResource(R.string.reminder_onboarding_title), text = stringResource(R.string.reminder_onboarding_title),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
@@ -89,7 +100,7 @@ fun ReminderStep(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(OnboardingSpace.lg)) Spacer(Modifier.height(OnboardingSpace.xl))
BenefitRow( BenefitRow(
icon = Icons.Filled.NotificationsActive, icon = Icons.Filled.NotificationsActive,
@@ -102,5 +113,30 @@ fun ReminderStep(
title = stringResource(R.string.reminder_benefit_duplicates_title), title = stringResource(R.string.reminder_benefit_duplicates_title),
body = stringResource(R.string.reminder_benefit_duplicates_body), body = stringResource(R.string.reminder_benefit_duplicates_body),
) )
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Filled.Tune,
title = stringResource(R.string.reminder_benefit_reversible_title),
body = stringResource(R.string.reminder_benefit_reversible_body),
)
}
}
/** A bell in the brand squircle — same silhouette as the permission hero. */
@Composable
private fun BellHero() {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.NotificationsActive,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
} }
} }

View File

@@ -0,0 +1,53 @@
package de.jeanlucmakiola.calendula.ui.permission
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
* grant. [onboardingDone] is null until DataStore's first emission so the
* step neither flashes for users who completed it nor gets skipped.
*/
@HiltViewModel
class ReminderOnboardingViewModel @Inject constructor(
private val prefs: SettingsPrefs,
private val scanner: ReminderScanner,
) : ViewModel() {
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
.map { done -> done as Boolean? }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/** Close the step, recording whether reminder notifications stay on. */
fun finish(remindersEnabled: Boolean) {
viewModelScope.launch {
prefs.setRemindersEnabled(remindersEnabled)
prefs.setReminderOnboardingDone()
// Nothing else re-arms the scan: turning reminders off cancels the
// alarm (#75).
scanner.scan()
}
}
/**
* Re-scan after the calendar permission is granted. The launch scan runs
* before the grant and bails out without arming anything, so without this
* the first alarm waits on the daily worker.
*/
fun rearmAfterGrant() {
scanner.scanInBackground()
}
}

View File

@@ -22,9 +22,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
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.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -55,20 +53,14 @@ import androidx.compose.runtime.remember
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.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
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.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
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.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -78,14 +70,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.domain.MatchSpan
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
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
@@ -93,8 +81,6 @@ import de.jeanlucmakiola.floret.components.SnackChip
import de.jeanlucmakiola.floret.components.SnackChipHeight import de.jeanlucmakiola.floret.components.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin import de.jeanlucmakiola.floret.components.SnackChipMargin
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -115,9 +101,11 @@ import java.time.format.FormatStyle
private const val CHIP_MILLIS = 4_000L private const val CHIP_MILLIS = 4_000L
/** /**
* Full-text event search over title / location / description: tap a result to * Full-text event search (top-bar entry). Type a query → matching events
* open its detail, long-press to select several and delete them at once (#80). * (title / location / description) across the whole calendar, newest-relevant
* A full-screen overlay hosted by [de.jeanlucmakiola.calendula.ui.CalendarHost]. * first; tap a result to open its detail. Long-press a result to enter selection
* mode and delete several at once (#80). A full-screen overlay hosted by
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -137,20 +125,12 @@ fun SearchScreen(
val inSelection = selection.isNotEmpty() val inSelection = selection.isNotEmpty()
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
// The ViewModel is activity-scoped, so a re-enter is what resets the last // Each fresh open starts blank and straight into typing. The ViewModel is
// search, its selection and any delete receipt still up. Peeking a result // activity-scoped so it outlives the overlay; clearing on (re)enter is what
// doesn't re-run this the screen stays composed under the detail. // resets a previous search. Peeking a result doesn't re-run this (the screen
// stays composed under the detail), so backing out keeps the query.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.setQuery("") viewModel.setQuery("")
viewModel.clearSelection()
viewModel.consumeDeleteResult()
}
// The field only exists out of selection mode, so a recreation that lands
// mid-selection has to wait for the clear above before it can be focused.
var focusPending by remember { mutableStateOf(true) }
LaunchedEffect(inSelection) {
if (inSelection || !focusPending) return@LaunchedEffect
focusPending = false
focusRequester.requestFocus() focusRequester.requestFocus()
keyboard?.show() keyboard?.show()
} }
@@ -177,9 +157,9 @@ fun SearchScreen(
} }
} }
// Back leaves selection mode before it leaves the screen, and without the // Back leaves selection mode before it leaves the screen and without the
// predictive-back scale-out — that would read as search closing and snapping // predictive-back scale-out, which would read as closing search only to have
// back. The two handlers are mutually exclusive on [inSelection]. // it snap back. The two handlers are mutually exclusive on [inSelection].
BackHandler(enabled = inSelection) { viewModel.clearSelection() } BackHandler(enabled = inSelection) { viewModel.clearSelection() }
Scaffold( Scaffold(
@@ -200,8 +180,9 @@ fun SearchScreen(
) )
}, },
) { padding -> ) { padding ->
// imePadding so the idle/empty message re-centres above the keyboard and // imePadding shrinks the content by the keyboard, so the centered
// the results list lifts clear of it. // idle/empty message re-centres in the space above it (and the results
// list lifts clear of the keyboard too).
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -277,8 +258,9 @@ fun SearchScreen(
} }
/** /**
* The search field and the contextual selection bar as one bar: the bar stays * The search field and the contextual selection bar as one bar: entering or
* in place across the swap, easing its colour and fading its slots through. * leaving selection mode keeps the bar itself in place, eases its container
* colour and fades its slots through, rather than cutting between two bars.
*/ */
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -303,8 +285,8 @@ private fun SearchTopBar(
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(), animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
label = "search-top-bar-color", label = "search-top-bar-color",
) )
// Held past the selection emptying, so the count has something to draw while // Held past the selection emptying so the count has something to draw while
// it fades out. // it fades out — by then the set is already at zero.
var lastCount by remember { mutableIntStateOf(0) } var lastCount by remember { mutableIntStateOf(0) }
if (selectedCount > 0) lastCount = selectedCount if (selectedCount > 0) lastCount = selectedCount
@@ -402,7 +384,6 @@ private fun SearchTopBar(
) )
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SearchResults( private fun SearchResults(
results: SearchUiState.Results, results: SearchUiState.Results,
@@ -411,52 +392,21 @@ private fun SearchResults(
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onToggle: (Long) -> Unit, onToggle: (Long) -> Unit,
) { ) {
val events = results.events
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
// No horizontal inset: GroupedRow already insets itself. // No horizontal inset here: GroupedRow already insets itself, and adding
// another 16dp made the results narrower than every other list.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) {
results.results.months.forEach { month ->
stickyHeader(key = "month-${month.year}-${month.monthNumber}") {
SearchSectionHeader(text = monthLabel(month))
}
hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
}
// Held back from the calendar above: matched only in the notes.
if (results.results.inDescriptions.isNotEmpty()) {
stickyHeader(key = "in-descriptions") {
SearchSectionHeader(text = stringResource(R.string.search_in_descriptions))
}
hitRows(
results.results.inDescriptions,
results,
selection,
inSelection,
onEventClick,
onToggle,
)
}
}
}
/** One section's rows, sharing a grouped-card run. */
private fun LazyListScope.hitRows(
hits: List<SearchHit>,
results: SearchUiState.Results,
selection: Set<Long>,
inSelection: Boolean,
onEventClick: (EventInstance) -> Unit,
onToggle: (Long) -> Unit,
) { ) {
itemsIndexed( itemsIndexed(
items = hits, items = events,
key = { _, hit -> hit.event.eventId }, key = { _, event -> event.eventId },
) { index, hit -> ) { index, event ->
val event = hit.event
val deletable = results.isDeletable(event) val deletable = results.isDeletable(event)
SearchResultRow( SearchResultRow(
hit = hit, event = event,
position = positionOf(index, hits.size), position = positionOf(index, events.size),
modifier = animateItemMotion(), modifier = animateItemMotion(),
selected = event.eventId in selection, selected = event.eventId in selection,
// A read-only calendar's row can't join a batch, so in selection // A read-only calendar's row can't join a batch, so in selection
@@ -472,33 +422,11 @@ private fun LazyListScope.hitRows(
) )
} }
} }
/** Pinned section label, styled like the agenda's day headers. */
@Composable
private fun SearchSectionHeader(text: String) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
/** "August 2026" in the locale's own field order. */
@Composable
private fun monthLabel(month: SearchMonth): String {
val locale = currentLocale()
return remember(month.year, month.monthNumber, locale) {
localizedDateFormatter(locale, "LLLLy")
.format(java.time.LocalDate.of(month.year, month.monthNumber, 1))
}
} }
@Composable @Composable
private fun SearchResultRow( private fun SearchResultRow(
hit: SearchHit, event: EventInstance,
position: Position, position: Position,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
selected: Boolean = false, selected: Boolean = false,
@@ -507,21 +435,12 @@ private fun SearchResultRow(
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
) { ) {
val event = hit.event
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current val soften = LocalSoftenColors.current
// On a picked row the headline is already recoloured for the secondary
// container, so the match is carried by weight alone there.
val highlight = SpanStyle(
fontWeight = FontWeight.Bold,
color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary,
)
GroupedRow( GroupedRow(
// Faded like a past event anywhere else in the app — search reaches back modifier = modifier,
// through the whole history. title = event.title,
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier, summary = searchSummary(event),
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
summary = searchSummary(hit, highlight),
position = position, position = position,
minHeight = 64.dp, minHeight = 64.dp,
selected = selected, selected = selected,
@@ -550,8 +469,9 @@ private fun SearchResultRow(
} }
/** /**
* The batch's receipt: how many went, and whether any refused. No Undo — the * The batch's receipt: how many events went, and whether any refused. Sits on
* provider can't put a deleted event back. * the FAB band so the shortened list stays visible behind it. Deletes aren't
* reversible through the provider, so this carries no Undo.
*/ */
@Composable @Composable
private fun DeleteOutcomeChip( private fun DeleteOutcomeChip(
@@ -572,11 +492,7 @@ private fun DeleteOutcomeChip(
deleteState.deleted, deleteState.deleted,
) )
} }
is BulkDeleteUiState.NeedsPermission -> if (deleteState.deleted > 0) { BulkDeleteUiState.NeedsPermission -> stringResource(R.string.event_delete_write_denied)
stringResource(R.string.search_delete_denied_partial, deleteState.deleted)
} else {
stringResource(R.string.event_delete_write_denied)
}
else -> null else -> null
} }
// Held past the state being consumed so the chip has something to draw while // Held past the state being consumed so the chip has something to draw while
@@ -600,31 +516,17 @@ private fun DeleteOutcomeChip(
} }
} }
/** [text] with every matched run emphasised. */ /** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
private fun marked(text: String, spans: List<MatchSpan>, style: SpanStyle): AnnotatedString =
if (spans.isEmpty()) {
AnnotatedString(text)
} else {
buildAnnotatedString {
append(text)
spans.forEach { addStyle(style, it.start, it.end) }
}
}
/**
* "Wed, 17 Jun 2026 · 09:00 · Office", with a second line excerpting the
* description when that is where the query matched.
*/
@Composable @Composable
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString { private fun searchSummary(event: EventInstance): String {
val event = hit.event
val locale = currentLocale() val locale = currentLocale()
val zone = remember { ZoneId.systemDefault() } val zone = remember { ZoneId.systemDefault() }
val start = remember(event.start, zone) { val start = remember(event.start, zone) {
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone) JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
} }
// From the span rule, not [start]: an all-day event sits at UTC midnight and // From the shared span rule, not [start]: an all-day event sits at UTC
// would name the day before west of UTC (#82). // midnight and would name the day before west of UTC (#82). The clock time
// below stays device-zone — it is only rendered for timed events.
val dateText = remember(event.start, event.end, event.isAllDay, locale) { val dateText = remember(event.start, event.end, event.isAllDay, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate()) .format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
@@ -635,23 +537,8 @@ private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString
} else { } else {
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start) remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
} }
return buildAnnotatedString { val base = "$dateText · $timeText"
append("$dateText · $timeText") return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
event.location?.takeIf { it.isNotBlank() }?.let { location ->
append(" · ")
val offset = length
append(location)
hit.locationSpans.forEach {
addStyle(highlight, offset + it.start, offset + it.end)
}
}
hit.descriptionSnippet?.let { snippet ->
append("\n")
val offset = length
append(snippet.text)
snippet.spans.forEach { addStyle(highlight, offset + it.start, offset + it.end) }
}
}
} }
@Composable @Composable

View File

@@ -6,9 +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.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchResults
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -28,9 +26,6 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import javax.inject.Inject import javax.inject.Inject
@@ -44,14 +39,16 @@ sealed interface SearchUiState {
/** A query ran but matched nothing. */ /** A query ran but matched nothing. */
data class Empty(val query: String) : SearchUiState data class Empty(val query: String) : SearchUiState
/** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */ /** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
data class Results( data class Results(
val results: SearchResults, val events: List<EventInstance>,
/** Rows from these are kept out of selection rather than failing later. */ /**
* Calendars among the results that can't be written to (WebCal, birthday
* mirrors, …). Their rows are excluded from selection rather than failing
* at delete time.
*/
val readOnlyCalendarIds: Set<Long> = emptySet(), val readOnlyCalendarIds: Set<Long> = emptySet(),
) : SearchUiState { ) : SearchUiState {
val events: List<EventInstance> get() = results.allHits.map { it.event }
fun isDeletable(event: EventInstance): Boolean = fun isDeletable(event: EventInstance): Boolean =
event.calendarId !in readOnlyCalendarIds event.calendarId !in readOnlyCalendarIds
} }
@@ -66,8 +63,8 @@ sealed interface BulkDeleteUiState {
/** Terminal: [deleted] events went, [failed] wouldn't. */ /** Terminal: [deleted] events went, [failed] wouldn't. */
data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState
/** WRITE_CALENDAR was revoked after [deleted] went; the rest wasn't tried. */ /** WRITE_CALENDAR was revoked mid-flight; nothing further was attempted. */
data class NeedsPermission(val deleted: Int) : BulkDeleteUiState data object NeedsPermission : BulkDeleteUiState
} }
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@@ -99,20 +96,12 @@ class SearchViewModel @Inject constructor(
if (q.length < MIN_QUERY_LENGTH) { if (q.length < MIN_QUERY_LENGTH) {
SearchUiState.Idle SearchUiState.Idle
} else { } else {
val now = Clock.System.now() val results = repository.searchEvents(q)
val zone = TimeZone.currentSystemDefault() if (results.isEmpty()) {
val results = EventSearch.search(
candidates = repository.searchEvents(q),
query = q,
now = now,
todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone),
zone = zone,
)
if (results.isEmpty) {
SearchUiState.Empty(q) SearchUiState.Empty(q)
} else { } else {
SearchUiState.Results( SearchUiState.Results(
results = results, events = sortNearestFirst(results),
readOnlyCalendarIds = readOnlyCalendarIds(), readOnlyCalendarIds = readOnlyCalendarIds(),
) )
} }
@@ -144,7 +133,10 @@ class SearchViewModel @Inject constructor(
_selection.value = emptySet() _selection.value = emptySet()
} }
/** Whether the batch needs a [RecurringWriteScope] decision before it runs. */ /**
* Whether the current selection contains a recurring event, i.e. whether the
* batch needs a [RecurringWriteScope] decision before it can run.
*/
fun selectionHasRecurring(): Boolean { fun selectionHasRecurring(): Boolean {
val results = state.value as? SearchUiState.Results ?: return false val results = state.value as? SearchUiState.Results ?: return false
val picked = _selection.value val picked = _selection.value
@@ -152,9 +144,10 @@ class SearchViewModel @Inject constructor(
} }
/** /**
* Delete every selected event. [scope] reaches the recurring ones only; a * Delete every selected event. [scope] applies to the recurring ones only
* one-off always goes whole. One at a time, so a single failure doesn't take * a one-off is always removed whole, whatever the batch decision was. Runs
* the rest with it — the tally lands in [deleteState]. * one event at a time so a single failure doesn't take the rest with it;
* the tally lands in [deleteState].
*/ */
fun deleteSelected(scope: RecurringWriteScope) { fun deleteSelected(scope: RecurringWriteScope) {
if (_deleteState.value == BulkDeleteUiState.Deleting) return if (_deleteState.value == BulkDeleteUiState.Deleting) return
@@ -184,7 +177,7 @@ class SearchViewModel @Inject constructor(
_selection.value = emptySet() _selection.value = emptySet()
_reload.value += 1 _reload.value += 1
_deleteState.value = if (denied) { _deleteState.value = if (denied) {
BulkDeleteUiState.NeedsPermission(deleted = deleted) BulkDeleteUiState.NeedsPermission
} else { } else {
BulkDeleteUiState.Done(deleted = deleted, failed = failed) BulkDeleteUiState.Done(deleted = deleted, failed = failed)
} }
@@ -213,4 +206,11 @@ class SearchViewModel @Inject constructor(
.filterNot { it.canModifyContents } .filterNot { it.canModifyContents }
.map { it.id } .map { it.id }
.toSet() .toSet()
/** Soonest upcoming (and ongoing) first, then the most recent past. */
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
val now = Clock.System.now()
val (upcoming, past) = events.partition { it.end >= now }
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
}
} }

View File

@@ -1,13 +1,24 @@
package de.jeanlucmakiola.calendula.ui.settings 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.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.clipToBounds
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.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.month.descriptionRes import de.jeanlucmakiola.calendula.ui.month.descriptionRes
@@ -16,6 +27,7 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.SelectedCheck import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
/** /**
@@ -39,22 +51,40 @@ internal fun MonthViewStylePicker(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val options = MonthViewStyle.entries val options = MonthViewStyle.entries
val reduceMotion = rememberReduceMotion()
FullScreenPicker( FullScreenPicker(
title = stringResource(R.string.settings_month_view_style), title = stringResource(R.string.settings_month_view_style),
onDismiss = onDismiss, onDismiss = onDismiss,
predictiveBack = true, predictiveBack = true,
) { ) {
ViewPreviewFrame( Box(
selected = selected, 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", label = "month-style-preview",
modifier = Modifier.padding(vertical = 8.dp),
) { shown -> ) { 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( MonthStylePreview(
style = shown, style = shown,
weekStart = weekStart, weekStart = weekStart,
height = PREVIEW_HEIGHT, height = PREVIEW_HEIGHT,
) )
} }
}
}
// The selected style's own blurb lives here, under its preview, rather // 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 // than on every row — the rows stay single-line and dense, and the words
// describe exactly what is being shown above. // describe exactly what is being shown above.
@@ -79,3 +109,4 @@ internal fun MonthViewStylePicker(
} }
private val PREVIEW_HEIGHT = 280.dp private val PREVIEW_HEIGHT = 280.dp
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -302,14 +302,13 @@ private fun ReportProblemRow(position: Position) {
@Composable @Composable
private fun LanguageRow(position: Position) { private fun LanguageRow(position: Position) {
val context = LocalContext.current val context = LocalContext.current
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
// Setting a locale recreates the activity; mirror the choice locally so the // Setting a locale recreates the activity; mirror the choice locally so the
// row updates instantly even before the recreation lands. // row updates instantly even before the recreation lands.
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) } var current by remember { mutableStateOf(AppLanguage.currentTag()) }
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml. // null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember(supported) { listOf<String?>(null) + supported } val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_language), title = stringResource(R.string.settings_language),

View File

@@ -42,8 +42,6 @@ data class SettingsUiState(
val showWeekNumbers: Boolean = false, val showWeekNumbers: Boolean = false,
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */ /** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
val todayButtonInToolbar: Boolean = false, val todayButtonInToolbar: Boolean = false,
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
val dragToReschedule: Boolean = true,
/** 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). */

View File

@@ -145,15 +145,9 @@ class SettingsViewModel @Inject constructor(
prefs.showWeekNumbers, prefs.showWeekNumbers,
prefs.agendaShowToday, prefs.agendaShowToday,
prefs.softenCalendarColors, prefs.softenCalendarColors,
combine(
prefs.todayButtonInToolbar, prefs.todayButtonInToolbar,
prefs.dragToReschedule, ) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
::Pair, DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
),
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
DisplayToggles(
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
)
}, },
) { view, screenRange, widgetRange, timeFormat, toggles -> ) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings( ViewSettings(
@@ -163,7 +157,6 @@ class SettingsViewModel @Inject constructor(
agendaShowToday = toggles.agendaShowToday, agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors, softenColors = toggles.softenColors,
todayButtonInToolbar = toggles.todayButtonInToolbar, todayButtonInToolbar = toggles.todayButtonInToolbar,
dragToReschedule = toggles.dragToReschedule,
) )
}, },
combine( combine(
@@ -196,7 +189,6 @@ class SettingsViewModel @Inject constructor(
agendaShowToday = views.agendaShowToday, agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors, softenColors = views.softenColors,
todayButtonInToolbar = views.todayButtonInToolbar, todayButtonInToolbar = views.todayButtonInToolbar,
dragToReschedule = views.dragToReschedule,
agendaShowRangeBar = misc.showRangeBar, agendaShowRangeBar = misc.showRangeBar,
autofocusEventTitle = misc.autofocusEventTitle, autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay, pastEventDisplay = misc.pastEventDisplay,
@@ -297,7 +289,6 @@ class SettingsViewModel @Inject constructor(
val agendaShowToday: Boolean, val agendaShowToday: Boolean,
val softenColors: Boolean, val softenColors: Boolean,
val todayButtonInToolbar: Boolean, val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
) )
private data class DisplayToggles( private data class DisplayToggles(
@@ -306,7 +297,6 @@ class SettingsViewModel @Inject constructor(
val agendaShowToday: Boolean, val agendaShowToday: Boolean,
val softenColors: Boolean, val softenColors: Boolean,
val todayButtonInToolbar: Boolean, val todayButtonInToolbar: Boolean,
val dragToReschedule: Boolean,
) )
private data class MiscSettings( private data class MiscSettings(
@@ -544,10 +534,6 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) } viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
} }
fun setDragToReschedule(enabled: Boolean) {
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
}
/** /**
* Switch the launcher label (#44). The card highlights at once, then settles * Switch the launcher label (#44). The card highlights at once, then settles
* on what the component state reports; the writes are binder round-trips and * on what the component state reports; the writes are binder round-trips and
@@ -591,15 +577,15 @@ class SettingsViewModel @Inject constructor(
/** /**
* Enable or disable [view] in the quick-switch cycle, via an atomic * Enable or disable [view] in the quick-switch cycle, via an atomic
* read-modify-write so a concurrent reorder can't clobber it. Any number of * read-modify-write so a concurrent reorder can't clobber it. The
* views may be turned off; below two the pill hides itself (#150). * MIN_ENABLED floor is re-checked inside the transform, because the screen's
* own guard reads an async-echoed snapshot.
*/ */
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) { fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
prefs.updateQuickSwitch { config -> prefs.updateQuickSwitch { config ->
config.copy( val next = if (enabled) config.enabled + view else config.enabled - view
enabled = if (enabled) config.enabled + view else config.enabled - view, if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
)
} }
} }
} }

View File

@@ -32,6 +32,7 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
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.PickerDescription import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.descriptionRes import de.jeanlucmakiola.calendula.ui.common.descriptionRes
@@ -54,8 +55,8 @@ import java.time.format.TextStyle as JavaTextStyle
* it belongs to, plus the two cross-view ordering lists (#24, #69). * it belongs to, plus the two cross-view ordering lists (#24, #69).
* *
* The quick-switch cycle and the drawer list are independent orders — a view * The quick-switch cycle and the drawer list are independent orders — a view
* off in the cycle is still reachable from the drawer, including when the cycle * off in the cycle is still reachable from the drawer. The cycle needs at least
* is emptied and the pill disappears (#150). * [QuickSwitchConfig.MIN_ENABLED] targets.
*/ */
@Composable @Composable
internal fun ViewsScreen( internal fun ViewsScreen(
@@ -113,7 +114,7 @@ internal fun ViewsScreen(
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_dim_completed), title = stringResource(R.string.settings_dim_completed),
summary = stringResource(R.string.settings_dim_completed_summary), summary = stringResource(R.string.settings_dim_completed_summary),
position = Position.Middle, position = Position.Bottom,
trailing = { trailing = {
Switch( Switch(
checked = state.dimCompletedEvents, checked = state.dimCompletedEvents,
@@ -122,18 +123,6 @@ internal fun ViewsScreen(
}, },
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) }, onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
) )
GroupedRow(
title = stringResource(R.string.settings_drag_to_reschedule),
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
position = Position.Bottom,
trailing = {
Switch(
checked = state.dragToReschedule,
onCheckedChange = viewModel::setDragToReschedule,
)
},
onClick = { viewModel.setDragToReschedule(!state.dragToReschedule) },
)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
SectionHeader(stringResource(R.string.settings_month_header)) SectionHeader(stringResource(R.string.settings_month_header))
@@ -220,6 +209,8 @@ internal fun ViewsScreen(
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))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn( ReorderableColumn(
items = config.order, items = config.order,
keyOf = { it }, keyOf = { it },
@@ -235,6 +226,8 @@ internal fun ViewsScreen(
trailing = { trailing = {
Switch( Switch(
checked = checked, checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) }, onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
) )
}, },

View File

@@ -1,21 +1,33 @@
package de.jeanlucmakiola.calendula.ui.settings 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.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.clipToBounds
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.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.ui.common.PickerDescription import de.jeanlucmakiola.calendula.ui.common.PickerDescription
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
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.SelectedCheck import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
/** /**
@@ -34,23 +46,39 @@ internal fun WeekStartPicker(
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val locale = currentLocale() val locale = currentLocale()
val reduceMotion = rememberReduceMotion()
val resolved = selected.resolveFirstDay(locale) val resolved = selected.resolveFirstDay(locale)
FullScreenPicker( FullScreenPicker(
title = stringResource(R.string.settings_week_start), title = stringResource(R.string.settings_week_start),
onDismiss = onDismiss, onDismiss = onDismiss,
predictiveBack = true, predictiveBack = true,
) { ) {
ViewPreviewFrame( Box(
selected = resolved, modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.height(PREVIEW_HEIGHT),
contentAlignment = Alignment.Center,
) {
Crossfade(
targetState = resolved,
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
label = "week-start-preview", label = "week-start-preview",
modifier = Modifier.padding(vertical = 8.dp),
) { day -> ) { day ->
Box(
modifier = Modifier
.clip(PREVIEW_SHAPE)
.background(MaterialTheme.colorScheme.surface)
.clipToBounds(),
) {
MonthStylePreview( MonthStylePreview(
style = monthStyle, style = monthStyle,
weekStart = day, weekStart = day,
height = PREVIEW_HEIGHT, height = PREVIEW_HEIGHT,
) )
} }
}
}
PickerDescription(stringResource(R.string.settings_week_start_hint)) PickerDescription(stringResource(R.string.settings_week_start_hint))
options.forEachIndexed { index, option -> options.forEachIndexed { index, option ->
val isSelected = option == selected val isSelected = option == selected
@@ -79,3 +107,4 @@ internal fun WeekStartPicker(
/** Short enough to leave the first options on screen under it. */ /** Short enough to leave the first options on screen under it. */
private val PREVIEW_HEIGHT = 200.dp private val PREVIEW_HEIGHT = 200.dp
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)

View File

@@ -1,6 +1,7 @@
package de.jeanlucmakiola.calendula.ui.week package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
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
@@ -51,7 +52,6 @@ import androidx.compose.runtime.getValue
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
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -59,7 +59,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -69,6 +71,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -86,13 +89,7 @@ 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.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
import de.jeanlucmakiola.calendula.ui.common.MAX_TIME_LINES
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
@@ -101,19 +98,13 @@ import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant import de.jeanlucmakiola.calendula.ui.common.startInstant
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.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
@@ -136,9 +127,7 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
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.time.isoWeekNumber import de.jeanlucmakiola.floret.time.isoWeekNumber
@@ -159,7 +148,7 @@ private val ALL_DAY_VERTICAL_PADDING = 6.dp
private val COLUMN_GAP = 2.dp private val COLUMN_GAP = 2.dp
/** Total all-day strip height for a week (0 when there are no all-day events). */ /** Total all-day strip height for a week (0 when there are no all-day events). */
internal fun WeekUiState.Success.allDayStripHeight(): Dp { private fun WeekUiState.Success.allDayStripHeight(): Dp {
if (allDaySpans.isEmpty()) return 0.dp if (allDaySpans.isEmpty()) return 0.dp
val lanes = allDaySpans.maxOf { it.lane } + 1 val lanes = allDaySpans.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2 return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
@@ -192,9 +181,21 @@ fun WeekScreen(
derivedStateOf { if (dimCompleted) nowState.value else null } derivedStateOf { if (dimCompleted) nowState.value else null }
} }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// The static header + all-day strip share the app bar's scrolled colour so
// the whole top region elevates together once the timeline scrolls under it.
val topSectionColor by animateColorAsState(
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
MaterialTheme.colorScheme.surfaceContainer
} else {
MaterialTheme.colorScheme.surface
},
label = "week-top-section-color",
)
val isOnCurrentWeek = when (val s = state) { val isOnCurrentWeek = when (val s = state) {
// True when today falls inside the displayed week — independent of which // True when today falls inside the displayed week — independent of which
// weekday the user picked as the first day. // weekday the user picked as the first day.
@@ -255,19 +256,19 @@ fun WeekScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
WeekTopBar( WeekTopBar(
weekStart = weekStart, weekStart = weekStart,
currentYear = currentYear, currentYear = currentYear,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
quickSwitchViews = quickSwitchViews,
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate, onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar, showTodayButton = todayInToolbar,
onToday = jumpToToday, onToday = jumpToToday,
scrollBehavior = scrollBehavior,
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -288,6 +289,7 @@ fun WeekScreen(
WeekContent( WeekContent(
state = state, state = state,
slideDir = slideDir, slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext, onSwipeNext = goNext,
onSwipePrev = goPrev, onSwipePrev = goPrev,
onRetry = jumpToToday, onRetry = jumpToToday,
@@ -307,6 +309,7 @@ fun WeekScreen(
private fun WeekContent( private fun WeekContent(
state: WeekUiState, state: WeekUiState,
slideDir: Int, slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit, onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit, onSwipePrev: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
@@ -371,6 +374,7 @@ private fun WeekContent(
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry) is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is WeekUiState.Success -> WeekSuccess( is WeekUiState.Success -> WeekSuccess(
state = s, state = s,
topSectionColor = topSectionColor,
scrollState = scrollState, scrollState = scrollState,
allDayHeight = allDayHeight, allDayHeight = allDayHeight,
dragController = dragController, dragController = dragController,
@@ -378,7 +382,7 @@ private fun WeekContent(
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onCreateAt = onCreateAt, onCreateAt = onCreateAt,
onDrop = { drop -> onDrop = { drop ->
move?.move( val took = move?.move(
MoveRequest( MoveRequest(
eventId = drop.event.eventId, eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(), beginMillis = drop.event.start.toEpochMilliseconds(),
@@ -386,6 +390,9 @@ private fun WeekContent(
target = MoveTarget.Start(drop.startInstant(zone)), target = MoveTarget.Start(drop.startInstant(zone)),
), ),
) )
// Refused, so nothing will land: let the copy go now
// rather than hold it out for a settle that never comes.
if (took != true) dragController.release()
}, },
) )
} }
@@ -395,8 +402,9 @@ private fun WeekContent(
} }
@Composable @Composable
internal fun WeekSuccess( private fun WeekSuccess(
state: WeekUiState.Success, state: WeekUiState.Success,
topSectionColor: Color,
scrollState: ScrollState, scrollState: ScrollState,
allDayHeight: Dp, allDayHeight: Dp,
dragController: TimelineDragController, dragController: TimelineDragController,
@@ -409,13 +417,13 @@ internal fun WeekSuccess(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface), .background(topSectionColor),
) { ) {
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay) WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
} }
// Breathing room between the top section and the scrolling timeline // Breathing room between the (colour-shifting) top section and the
// below. // scrolling timeline below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Timeline( Timeline(
state = state, state = state,
@@ -435,25 +443,20 @@ private fun WeekTopBar(
currentYear: Int, currentYear: Int,
selectedView: CalendarView, selectedView: CalendarView,
onCycleView: () -> Unit, onCycleView: () -> Unit,
quickSwitchViews: List<CalendarView>,
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean, showTodayButton: Boolean,
onToday: () -> Unit, onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) { ) {
val locale = currentLocale() val locale = currentLocale()
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
formatWeekTitle(weekStart, locale, currentYear) to
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
}
TopAppBar( TopAppBar(
title = { title = {
CalendarTitleButton( CalendarTitleButton(
title = title, title = formatWeekTitle(weekStart, locale, currentYear),
currentDate = weekStart, currentDate = weekStart,
onJumpToDate = onJumpToDate, onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -474,17 +477,17 @@ private fun WeekTopBar(
} }
ViewSwitcherPill( ViewSwitcherPill(
current = selectedView, current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView, onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
) )
}, },
// Deliberately flat: M3 lifts the bar to mark content scrolling under // Match the static top section exactly: plain surface, lifting to
// it, but here the bar meets the header on the same surface and the // surfaceContainer once content scrolls under the bar.
// tint is what makes that seam look like a separate block (#186).
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
scrollBehavior = scrollBehavior,
) )
} }
@@ -500,7 +503,7 @@ private fun WeekDayHeader(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET), .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. The start inset centres // badge lines up vertically with the date numbers. The start inset centres
@@ -596,11 +599,7 @@ private fun AllDayStrip(
// Height is hoisted + animated so it slides and resizes smoothly; // Height is hoisted + animated so it slides and resizes smoothly;
// padding sits inside it so the content area is lanes * row height. // padding sits inside it so the content area is lanes * row height.
.height(height) .height(height)
.padding( .padding(vertical = ALL_DAY_VERTICAL_PADDING),
top = ALL_DAY_VERTICAL_PADDING,
bottom = ALL_DAY_VERTICAL_PADDING,
end = TIMELINE_CONTENT_END_INSET,
),
) { ) {
// Keep the gutter-width offset so the bars line up with the day columns. // Keep the gutter-width offset so the bars line up with the day columns.
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
@@ -648,21 +647,18 @@ private fun AllDayBar(
val fill = eventFill(event.color, dark, soften) 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(fill, EventChipShape) .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 },
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
val titleOverflow = eventTitleOverflow()
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = TextOverflow.Ellipsis,
softWrap = titleOverflow.softWrap,
color = eventInk(fill), color = eventInk(fill),
textDecoration = declinedDecoration(event.isDeclined),
) )
} }
} }
@@ -709,7 +705,6 @@ private fun Timeline(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxHeight() .fillMaxHeight()
.padding(end = TIMELINE_CONTENT_END_INSET)
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState) .verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it }, .onGloballyPositioned { dragController.geometry.viewport = it },
@@ -776,12 +771,6 @@ private fun DayColumnCard(
LaunchedEffect(blocks, dragController.settling) { LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks) dragController.noteGrid(date, blocks)
} }
// The tap detector outlives the composition that installed it — a pinch or a
// Settings change moves the hour height without restarting it — so it reads
// the height and the callback through state handles instead of capturing
// them, or taps land on the scale the column had before the zoom (#148).
val currentHourPx = rememberUpdatedState(hourPx)
val currentOnCreateAt = rememberUpdatedState(onCreateAt)
Card( Card(
// Plain rectangular columns — the soft corners come from the outer // Plain rectangular columns — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges. // rounded scroll viewport, so inner rounding would look odd at the edges.
@@ -801,10 +790,8 @@ private fun DayColumnCard(
// blocks are consumed by their own handler first. Snaps to hour. // blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) { .pointerInput(date) {
detectTapGestures { offset -> detectTapGestures { offset ->
currentOnCreateAt.value( val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
date, onCreateAt(date, hour * 60)
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
} }
}, },
) { ) {
@@ -840,7 +827,7 @@ private fun DayColumnCard(
.offset(x = place.x, y = place.y) .offset(x = place.x, y = place.y)
.width(place.width) .width(place.width)
.height(place.height) .height(place.height)
.padding(horizontal = BLOCK_OUTER_INSET), .padding(horizontal = 1.dp),
) )
} }
} }
@@ -881,47 +868,24 @@ private fun EventBlock(
// Only full-width (non-overlapping) blocks that are tall enough show the // Only full-width (non-overlapping) blocks that are tall enough show the
// time. On narrow overlapping columns we drop it so the title can wrap to // time. On narrow overlapping columns we drop it so the title can wrap to
// fill the whole block, mirroring Google Calendar — and a block that cannot // fill the whole block, mirroring Google Calendar — and a block that cannot
// afford both lines spends its space on the title. Height decides that on // afford both lines spends its space on the title.
// its own: a duration threshold would keep hiding the time on a half-hour val showTime = block.endMin - block.startMin >= 45 &&
// block the user has pinched open to three times the room it needs. block.laneCount == 1 &&
val showTime = block.laneCount == 1 &&
available >= titleLineHeight + timeLineHeight available >= titleLineHeight + timeLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// A short block drops the title rather than serving a horizontally sliced // A short block drops the title rather than serving a horizontally sliced
// one: half a letter reads as a rendering fault, while a bare colour chip // one: half a letter reads as a rendering fault, while a bare colour chip
// reads as what it is — an event too brief to label. Tap still opens it, and // reads as what it is — an event too brief to label. Tap still opens it, and
// the semantics description carries the full title either way. // the semantics description carries the full title either way.
val showTitle = available >= titleLineHeight val showTitle = available >= titleLineHeight
// The title is served first, out of everything the block has left once the // Wrap the title across as many lines as the block can fit — but only once a
// time is down to one line — but only takes the lines it will actually use, // line is wide enough to hold more than a syllable. Below that the extra
// and only wraps at all once a line is wide enough to hold more than a // lines just stack fragments of the word, and one ellipsised line reads
// syllable. Below that the extra lines just stack fragments of the word. // better.
val contentHeight = available - if (showTime) timeLineHeight else 0.dp val contentHeight = available - if (showTime) timeLineHeight else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) { val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
1 1
} else { } else {
blockTextLines( (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
text = title,
style = MaterialTheme.typography.labelMedium,
textWidth = textWidth,
max = titleBudget,
)
}
// A week column is narrower than a "09:3011:00" range, so the label takes a
// second line rather than lose its end — but only out of a line the title
// measured itself as not needing, never one it would have filled.
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime && spare >= timeLineHeight) {
blockTextLines(
text = timeLabel,
style = MaterialTheme.typography.labelSmall,
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
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)
@@ -929,35 +893,29 @@ private fun EventBlock(
val fill = eventFill(block.event.color, dark, soften) val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event) // A block clipped at the top continues from the previous day: its top edge
// The drop takes this offset back off, so a tail clipped at midnight lands // is midnight, not the event's start, so dragging it would invent a time.
// where the event's own start belongs (#253). val draggable = moveAction != null && block.beginsOn(date, zone)
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
dragController.begin(block, clipOffset, pointer, blockRoot)
},
onMove = dragController::move, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel, onCancel = dragController::cancel,
) )
val lifted = draggable && dragController.ghosts(block) val lifted = draggable && dragController.ghosts(block, date)
val ghost = ghostAlpha(lifted) val ghost = ghostAlpha(lifted)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape) .background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick) .clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass; // After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up. // the tap still works, since a drag consumes the up.
.then(dragModifier) .then(dragModifier)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp) .padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -965,19 +923,18 @@ private fun EventBlock(
) { ) {
Column { Column {
if (showTitle) { if (showTitle) {
BlockTitle( Text(
title = title, text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines, maxLines = titleMaxLines,
textWidth = textWidth, overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f), color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
) )
} }
if (showTime) { if (showTime) {
BlockTimeLabel( BlockTimeLabel(
label = timeLabel, label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
maxLines = timeMaxLines,
) )
} }
} }
@@ -1009,12 +966,7 @@ private fun WeekLoading() {
// Same scale resolution as the loaded timeline, so the skeleton's // Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives. // columns don't resize the moment the real week arrives.
val totalHeight = scale.hourHeight(maxHeight) * 24 val totalHeight = scale.hourHeight(maxHeight) * 24
Row( Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
modifier = Modifier
.fillMaxSize()
.padding(end = TIMELINE_CONTENT_END_INSET)
.verticalScroll(scrollState),
) {
Spacer(Modifier.width(GUTTER_WIDTH)) Spacer(Modifier.width(GUTTER_WIDTH))
repeat(7) { repeat(7) {
Box( Box(
@@ -1049,18 +1001,13 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
* "December" with no year would be actively misleading while the reader is * "December" with no year would be actively misleading while the reader is
* looking straight at January dates. * looking straight at January dates.
*/ */
private fun formatWeekTitle( private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
weekStart: LocalDate,
locale: Locale,
currentYear: Int,
abbreviated: Boolean = false,
): String {
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY) val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
return formatCalendarTitle( return formatCalendarTitle(
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1), date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
locale = locale, locale = locale,
currentYear = currentYear, currentYear = currentYear,
skeleton = if (abbreviated) "LLL" else "LLLL", skeleton = "LLLL",
forceYear = weekEnd.year != weekStart.year, forceYear = weekEnd.year != weekStart.year,
) )
} }

View File

@@ -206,17 +206,6 @@ internal fun EventInstance.coversDay(day: LocalDate, zone: TimeZone): Boolean {
return start < dayEnd && end > dayStart return start < dayEnd && end > dayStart
} }
/**
* True if this event touches more than one calendar day in [zone] — an all-day
* event covering a range, or a timed one crossing midnight. Occurrences are
* contiguous, so covering the day after the first is the whole question.
*/
internal fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean {
val anchor = if (isAllDay) TimeZone.UTC else zone
val secondDay = start.toLocalDateTime(anchor).date.plus(1, DateTimeUnit.DAY)
return coversDay(secondDay, zone)
}
/** /**
* Clip [events] to a single [day] and assign lanes so overlapping events render * Clip [events] to a single [day] and assign lanes so overlapping events render
* side-by-side. Lane count is computed per overlap-cluster (a maximal run of * side-by-side. Lane count is computed per overlap-cluster (a maximal run of

View File

@@ -1,76 +0,0 @@
package de.jeanlucmakiola.calendula.ui.week
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.sampleTimelineEvents
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.coroutines.flow.first
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
/**
* A live, scaled-down Week view for the onboarding view chooser. The week and
* today's column are real; only the events are stand-ins.
*
* The timeline is scrolled to the working day rather than to midnight — the
* live view centres on noon once it knows its own scroll range, which a preview
* that is never interacted with would otherwise never do.
*/
@Composable
internal fun WeekViewPreview(
weekStart: DayOfWeek,
height: Dp,
modifier: Modifier = Modifier,
) {
val zone = remember { TimeZone.currentSystemDefault() }
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
val state = remember(today, weekStart, zone) { sampleWeekState(today, weekStart, zone) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range is noon — the same centring the live view does.
scrollState.scrollTo(scrollState.maxValue / 2)
}
ScaledViewPreview(height = height, modifier = modifier) {
WeekSuccess(
state = state,
scrollState = scrollState,
allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(),
onEventClick = {},
onOpenDay = {},
onCreateAt = { _, _ -> },
onDrop = {},
)
}
}
/** Sample week state, laid out through the same helpers the live view uses. */
private fun sampleWeekState(
today: LocalDate,
weekStart: DayOfWeek,
zone: TimeZone,
): WeekUiState.Success {
val start = today.startOfWeek(weekStart)
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
val events = sampleTimelineEvents(days, today, zone)
return WeekUiState.Success(
weekStart = start,
today = today,
days = days,
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
timedByDay = days.associateWith { layoutDay(events, it, zone) },
)
}

View File

@@ -1,115 +0,0 @@
package de.jeanlucmakiola.calendula.widget
import android.app.AlarmManager
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.core.content.getSystemService
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
/**
* Holds the app's own wake-up for the next local midnight, so the home-screen
* widgets roll "today" over on the day boundary (#228).
*
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
* implicit broadcast — a manifest-declared receiver has not been given it since
* Android 8, leaving only the throttled `updatePeriodMillis`.
*
* Exactly one alarm exists at a time and every firing re-arms the next, the same
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
* survives doze (plain `set` does not), a rollover a few minutes late is
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
*/
object WidgetRolloverScheduler {
/**
* Fire just *after* midnight: an alarm delivered a few milliseconds early
* would still read the old date and re-arm for an instant later.
*/
internal val ROLLOVER_SLACK = 5.seconds
/**
* Arm the next rollover, or cancel a pending one when no widget is placed.
* Idempotent, so every trigger (boot, app start, widget added/removed, the
* rollover itself, a clock or timezone change) can just call it.
*/
fun sync(context: Context) {
val appContext = context.applicationContext
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
val pendingIntent = rolloverPendingIntent(appContext)
if (!hasPlacedWidgets(appContext)) {
alarmManager.cancel(pendingIntent)
return
}
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
)
}
/**
* The instant just after the next local midnight following [now] in [zone].
*
* The *actual* start of day, not 00:00, so it holds where a DST jump means
* midnight never happens (Havana) and where a date-line move skips a whole
* local date (Apia, December 2011) — the loop walks on to the next real day.
*
* The mirror case — a zone rewinding *across* midnight, so the day starts
* twice — resolves to the earlier start and runs an hour ahead of the clock.
* No live tz entry does that (Brazil dropped DST in 2019) and
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
*/
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
val date = now.toLocalDateTime(zone).date
var days = 1
while (days <= MAX_LOOKAHEAD_DAYS) {
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
if (candidate > now) return candidate
days++
}
// Unreachable for any zone in the tz database. Deliberately an hour and
// not the slack: a 5-second retry would just hit this branch again and
// wake the device in a loop.
return now + 1.hours
}
private fun hasPlacedWidgets(context: Context): Boolean {
val manager = AppWidgetManager.getInstance(context) ?: return false
return PROVIDERS.any {
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
}
}
private fun rolloverPendingIntent(context: Context): PendingIntent =
PendingIntent.getBroadcast(
context,
ROLLOVER_REQUEST_CODE,
Intent(context, WidgetUpdateReceiver::class.java)
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private val PROVIDERS = listOf(
MonthWidgetReceiver::class.java,
AgendaWidgetReceiver::class.java,
)
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
/** A gap of more than a couple of days does not exist in any tz database entry. */
private const val MAX_LOOKAHEAD_DAYS = 3
}

View File

@@ -4,7 +4,6 @@ import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.glance.GlanceTheme import androidx.glance.GlanceTheme
import androidx.glance.material3.ColorProviders import androidx.glance.material3.ColorProviders
import androidx.glance.text.TextDecoration
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
@@ -35,10 +34,3 @@ fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
} }
GlanceTheme(colors = colors, content = content) GlanceTheme(colors = colors, content = content)
} }
/**
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
* reads the same on the home screen as it does inside the app (#180).
*/
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
if (isDeclined) TextDecoration.LineThrough else TextDecoration.None

View File

@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.widget
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.SystemClock
import androidx.glance.appwidget.updateAll import androidx.glance.appwidget.updateAll
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
@@ -11,110 +10,32 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicLong
/** /**
* Redraws both home-screen widgets when their data goes stale, and keeps the * Redraws both home-screen widgets when their data goes stale. Triggered by:
* midnight rollover alarm armed. Triggered by:
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change, * - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
* so it covers both the app's own writes and external sync. * so it covers both the app's own writes and external sync.
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] — * - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
* the day boundary, so "today" highlighting and the agenda's past-event * and the upcoming window roll over at midnight / on a clock change.
* dimming move on (#228).
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
* re-arm.
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
* latter is also what arms installs upgrading into the fix.
* *
* `DATE_CHANGED` rides along as a free extra the system may or may not send — * Both widgets also carry an `updatePeriodMillis` backstop in their provider
* see [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the * XML, and the month widget's refresh button forces an immediate redraw.
* provider XML and the month widget's refresh button.
*
* Every action that dropped, consumed or invalidated the alarm re-arms it, and
* `PROVIDER_CHANGED` re-arms at most once every [DATA_REARM_INTERVAL_MILLIS]
* ([dataRearmDue]). The alarm is inexact, so the system may drop it outright in
* a restricted standby bucket; nothing in ordinary daily use would then notice,
* and the widget sits on yesterday's date. A calendar change is the one signal
* that keeps arriving — but it arrives on *every* write (each drag, each event
* of a thousand-event import), which is what the interval is for.
*
* Exported for the system broadcasts; an extra redraw from another app is
* harmless.
*/ */
class WidgetUpdateReceiver : BroadcastReceiver() { class WidgetUpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
// Exported, so anything can reach it with an explicit intent. Nothing
// here crosses a trust boundary, but narrowing to the actions we asked
// for keeps a stray broadcast from costing two wide provider reads.
if (intent.action !in HANDLED_ACTIONS) return
val appContext = context.applicationContext
val rearm = intent.action in REARM_ACTIONS ||
(intent.action == Intent.ACTION_PROVIDER_CHANGED && dataRearmDue())
// The host sends APPWIDGET_UPDATE after the re-arm-only ones anyway, so
// redrawing there would only repeat the work in a cold process, at the
// moment the device is most contended.
val redraw = intent.action !in REARM_ONLY_ACTIONS
val pending = goAsync() val pending = goAsync()
val appContext = context.applicationContext
// Calendar data may have changed (sync / our own write) — drop the cached // Calendar data may have changed (sync / our own write) — drop the cached
// month window so the widgets reload fresh. Month paging does NOT call // month window so the widgets reload fresh. Month paging does NOT call
// this, so arrow taps stay instant. // this, so arrow taps stay instant.
if (redraw) invalidateMonthWidgetCache() invalidateMonthWidgetCache()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try { try {
// Re-armed first, so the next day boundary is covered whatever
// the redraw does — and off the receiving thread, since it is a
// handful of binder calls (`CalendulaApp` moves it for the same
// reason).
if (rearm) WidgetRolloverScheduler.sync(appContext)
if (redraw) {
AgendaWidget().updateAll(appContext) AgendaWidget().updateAll(appContext)
MonthWidget().updateAll(appContext) MonthWidget().updateAll(appContext)
}
} finally { } finally {
pending.finish() pending.finish()
} }
} }
} }
companion object {
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
private val REARM_ONLY_ACTIONS = setOf(
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
)
/**
* The actions that leave no alarm standing: the two above wipe it, the
* rollover consumed itself, and a clock, zone or day change moved the
* boundary the pending one was set for.
*/
internal val REARM_ACTIONS = REARM_ONLY_ACTIONS + setOf(
ACTION_ROLLOVER,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
internal val HANDLED_ACTIONS = REARM_ACTIONS + Intent.ACTION_PROVIDER_CHANGED
/** How rarely a calendar change may re-arm; see the class doc. */
private const val DATA_REARM_INTERVAL_MILLIS = 15 * 60_000L
/** Uptime of the last data-change re-arm, 0 for none in this process. */
private val lastDataRearmMillis = AtomicLong(0L)
/**
* Whether a calendar change may re-arm now — the backstop for an alarm
* the system dropped, throttled so a burst of writes costs one pass.
*/
private fun dataRearmDue(): Boolean {
val now = SystemClock.elapsedRealtime()
val last = lastDataRearmMillis.get()
val due = last == 0L || now - last >= DATA_REARM_INTERVAL_MILLIS
return due && lastDataRearmMillis.compareAndSet(last, now)
}
}
} }

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
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
@@ -25,6 +24,7 @@ import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.lazy.LazyColumn
import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.lazy.items
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.updateAll
import androidx.glance.background import androidx.glance.background
import androidx.glance.currentState import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition
@@ -74,6 +74,11 @@ import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
import java.util.Locale import java.util.Locale
/**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
* of events grouped under day headers (the Google "Schedule" widget model).
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
*/
/** /**
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]). * Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
* The range is read reactively in the composition ([currentState]) so a settings * The range is read reactively in the composition ([currentState]) so a settings
@@ -108,11 +113,6 @@ internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_to
*/ */
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size") internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
/**
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
* of events grouped under day headers (the Google "Schedule" widget model).
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
*/
class AgendaWidget : GlanceAppWidget() { class AgendaWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
@@ -135,23 +135,17 @@ class AgendaWidget : GlanceAppWidget() {
} }
} }
/** /** Re-reads the calendar and redraws the widget (header refresh button). */
* Redraws the widget (header refresh button). Targets the tapped widget's own id
* rather than `updateAll`, whose provider-name lookup is empty in a process a tap
* woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the
* calendar in the `provideGlance` preamble; a live session only recomposes from
* the snapshot it already has (see [AGENDA_RANGE_KEY]).
*/
class RefreshAgendaAction : ActionCallback { class RefreshAgendaAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
AgendaWidget().update(context.applicationContext, glanceId) AgendaWidget().updateAll(context.applicationContext)
} }
} }
/** /**
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews * Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
* stays well inside the binder transaction limit regardless of range and calendar * stays well inside the binder transaction limit regardless of range and calendar
* size (see the [SizeMode.Single] note above). Far more than fits on screen — a * 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 * user scrolling a home-screen widget past a hundred rows is not a case worth
* risking a failed update for. * risking a failed update for.
*/ */
@@ -422,11 +416,7 @@ private fun EventRow(
Text( Text(
text = title, text = title,
maxLines = 1, maxLines = 1,
style = TextStyle( style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
color = titleColor,
fontSize = metrics.eventTitle,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
) )
Text( Text(
text = eventTimeSummary(context, event, day, is24Hour), text = eventTimeSummary(context, event, day, is24Hour),

View File

@@ -1,10 +1,7 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.GlanceAppWidgetReceiver
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
/** /**
* Host-facing receiver for the agenda widget. Declared in the manifest with the * Host-facing receiver for the agenda widget. Declared in the manifest with the
@@ -13,32 +10,4 @@ import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
*/ */
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() { class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = AgendaWidget() override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
override fun onEnabled(context: Context) {
super.onEnabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
* no month widget is left either.
*/
override fun onDisabled(context: Context) {
super.onDisabled(context)
WidgetRolloverScheduler.sync(context)
}
/**
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
*/
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray,
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
WidgetRolloverScheduler.sync(context)
}
} }

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
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
@@ -26,6 +25,7 @@ import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import androidx.glance.background import androidx.glance.background
import androidx.glance.currentState import androidx.glance.currentState
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
@@ -33,7 +33,6 @@ import androidx.glance.layout.Box
import androidx.glance.layout.Column import androidx.glance.layout.Column
import androidx.glance.layout.Row import androidx.glance.layout.Row
import androidx.glance.layout.Spacer import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxHeight
import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height import androidx.glance.layout.height
@@ -98,27 +97,10 @@ private fun yearMonthOf(index: Int): YearMonth =
* inter-cell seam, with rounded end caps. The displayed month lives in Glance * inter-cell seam, with rounded end caps. The displayed month lives in Glance
* state and is read reactively in the composition ([currentState]) so the arrows * state and is read reactively in the composition ([currentState]) so the arrows
* move it via plain recomposition, not a (here-unreliable) widget session reload. * move it via plain recomposition, not a (here-unreliable) widget session reload.
*
* Everything here is written to keep the published `RemoteViews` small. A widget
* update reaches the launcher as a *oneway* binder call, and the host's async
* buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and
* the system drops the whole host, freezing every widget on the home screen until
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
* host reports, so the ceiling arrives at half the view count you would expect: a
* grid that emitted a view per cell blew past it after three taps (#214). Every
* row here emits only what it fills.
*/ */
class MonthWidget : GlanceAppWidget() { class MonthWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition override val stateDefinition = PreferencesGlanceStateDefinition
/**
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
* return early from `GlanceAppWidget.resize` — under those the grid is never
* recomposed for a new size and a resized widget clips instead of reflowing.
* Exact costs one serialised copy per host size, which is affordable now that
* a rendering is ~190 views rather than ~740.
*/
override val sizeMode = SizeMode.Exact override val sizeMode = SizeMode.Exact
override suspend fun provideGlance(context: Context, id: GlanceId) { override suspend fun provideGlance(context: Context, id: GlanceId) {
@@ -136,35 +118,15 @@ class MonthWidget : GlanceAppWidget() {
} }
} }
/** /** Step the displayed month by the `delta` action parameter (±1). */
* Step the displayed month by the `delta` action parameter (±1).
*
* Redrawn through [GlanceAppWidget.update] with the id the callback was handed,
* never `updateAll`. `updateAll` resolves its targets through the provider-name
* -> app-widget-id map Glance persists for the *receiver*, and in a process that
* has done nothing else yet — the one a tap wakes after a reboot — that lookup
* comes back empty and the redraw is dropped: the state write lands, nothing
* recomposes, and the arrows read as dead until something else opens the app and
* starts a session (#18). The tapped widget's own id needs no lookup.
*/
class ShiftMonthAction : ActionCallback { class ShiftMonthAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
val delta = parameters[deltaKey] ?: 0 val delta = parameters[deltaKey] ?: 0
updateAppWidgetState(context, glanceId) { prefs -> updateAppWidgetState(context, glanceId) { prefs ->
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone()) val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
val next = cur + delta prefs[MONTH_INDEX_KEY] = cur + delta
// Landing back on the current month clears the key, so the widget
// goes back to *following* the date rather than being pinned to
// whichever month was current at the tap. Paging out and back is the
// workaround #228's reporter used, and pinning it there would have
// stuck them on that month once it stopped being the current one.
if (next == currentMonthIndex(systemZone())) {
prefs.remove(MONTH_INDEX_KEY)
} else {
prefs[MONTH_INDEX_KEY] = next
} }
} MonthWidget().updateAll(context.applicationContext)
MonthWidget().update(context.applicationContext, glanceId)
} }
companion object { companion object {
@@ -172,11 +134,11 @@ class ShiftMonthAction : ActionCallback {
} }
} }
/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */ /** Jump the displayed month back to the current month. */
class ResetMonthAction : ActionCallback { class ResetMonthAction : ActionCallback {
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) } updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
MonthWidget().update(context.applicationContext, glanceId) MonthWidget().updateAll(context.applicationContext)
} }
} }
@@ -324,31 +286,9 @@ private fun WeekRow(
colW: Dp, colW: Dp,
modifier: GlanceModifier, modifier: GlanceModifier,
) { ) {
val context = LocalContext.current Column(modifier = modifier.fillMaxWidth()) {
val hidden = week.hiddenPerDay() // Day numbers.
Box(modifier = modifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
// Day-open targets, one full-height strip per column, sitting *under* the
// content: a tap anywhere in a day still opens it, as in the app, without
// every cell in the column carrying a click target of its own. The content
// above is not clickable, so touches fall through to this layer; only an
// event bar opts out to open its own detail.
Row(modifier = GlanceModifier.fillMaxSize()) {
week.days.forEach { date ->
Box(
modifier = GlanceModifier
.width(colW)
.fillMaxHeight()
.clickable(openDayAction(context, date)),
) {}
}
}
Column(modifier = GlanceModifier.fillMaxWidth()) {
// Day numbers. Fixed height so every week's number row lines up whether
// or not it holds today's larger pill.
Row(
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
) {
week.days.forEach { date -> week.days.forEach { date ->
DayNumber( DayNumber(
date = date, date = date,
@@ -361,37 +301,13 @@ private fun WeekRow(
Spacer(GlanceModifier.height(2.dp)) Spacer(GlanceModifier.height(2.dp))
// 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.
// Lanes past the last one this week fills emit nothing at all. repeat(MAX_LANES) { lane ->
repeat(week.usedLanes()) { lane ->
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW) LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
Spacer(GlanceModifier.height(1.dp)) Spacer(GlanceModifier.height(1.dp))
} }
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW) OverflowRow(week = week, colW = colW)
} }
} }
}
/**
* How many lanes this week actually fills, counting from the top. The grid draws
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
* per day and most weeks leave the last two empty.
*/
private fun MonthWeek.usedLanes(): Int {
for (lane in MAX_LANES - 1 downTo 0) {
val filled = spans.any { it.lane == lane } ||
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
if (filled) return lane + 1
}
return 0
}
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
}
/** /**
* Open [date]'s day view rooted in the month view (so back returns to the month * Open [date]'s day view rooted in the month view (so back returns to the month
@@ -402,14 +318,25 @@ private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
private fun openDayAction(context: Context, date: LocalDate) = private fun openDayAction(context: Context, date: LocalDate) =
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)) actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
/**
* A day's number. Every day but today is a bare centred [Text] carrying the column
* width itself — a wrapping Box costs three views, and there are 42 of these.
* Today keeps its filled circle, which does need the Box.
*/
@Composable @Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) { private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val style = TextStyle( val context = LocalContext.current
Box(
modifier = GlanceModifier
.width(colW)
.height(DAY_NUMBER_HEIGHT)
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.Center,
) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
contentAlignment = Alignment.Center,
) {
Text(
text = date.day.toString(),
style = TextStyle(
color = when { color = when {
isToday -> GlanceTheme.colors.onPrimary isToday -> GlanceTheme.colors.onPrimary
inMonth -> GlanceTheme.colors.onSurface inMonth -> GlanceTheme.colors.onSurface
@@ -417,78 +344,38 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
}, },
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center, ),
) )
if (!isToday) {
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
return
}
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
Box(
modifier = GlanceModifier
.size(DAY_NUMBER_HEIGHT)
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
.background(GlanceTheme.colors.primary),
contentAlignment = Alignment.Center,
) {
Text(text = date.day.toString(), style = style)
} }
} }
} }
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
private data class LaneCell(val event: EventInstance?, val cols: Int)
/**
* A lane split into bars and the gaps between them, with consecutive blank columns
* merged into one gap: the week's day-open taps come from the strip underneath, so
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
*/
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
val cells = mutableListOf<LaneCell>()
var gap = 0
var col = 0
fun closeGap() {
if (gap > 0) cells += LaneCell(null, gap)
gap = 0
}
while (col < 7) {
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
when {
span != null -> {
closeGap()
cells += LaneCell(span.event, span.endCol - col + 1)
col = span.endCol + 1
}
timed != null -> {
closeGap()
cells += LaneCell(timed, 1)
col += 1
}
else -> {
gap += 1
col += 1
}
}
}
closeGap()
return cells
}
@Composable @Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) { private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
week.laneCells(lane).forEach { cell -> var col = 0
if (cell.event == null) { while (col < 7) {
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT)) val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
if (span != null) {
val cols = span.endCol - col + 1
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
col = span.endCol + 1
} else { } else {
SpanBar( val timed = timedEventAt(week, lane, col, week.days[col])
event = cell.event, if (timed != null) {
dark = dark, SpanBar(event = timed, dark = dark, soften = soften, width = colW)
soften = soften, } else {
width = colW * cell.cols, // Empty lane cell: a tap opens that day, so blank space in a
) // day column is a day-open target just like the number is.
Box(
GlanceModifier
.width(colW)
.height(LANE_HEIGHT)
.clickable(openDayAction(context, week.days[col])),
) {}
}
col += 1
} }
} }
} }
@@ -527,39 +414,41 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
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( style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
color = ColorProvider(eventInk(fill)),
fontSize = 9.sp,
textDecoration = glanceDeclinedDecoration(event.isDeclined),
),
modifier = GlanceModifier.padding(horizontal = 3.dp), modifier = GlanceModifier.padding(horizontal = 3.dp),
) )
} }
} }
} }
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
@Composable @Composable
private fun OverflowRow(hidden: List<Int>, colW: Dp) { private fun OverflowRow(week: MonthWeek, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) { Row(modifier = GlanceModifier.fillMaxWidth()) {
var gap = 0 week.days.forEachIndexed { col, date ->
hidden.forEach { count -> val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
if (count == 0) { val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
gap += 1 val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
return@forEach val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
} // The overflow row is part of the day column too: tapping it (whether
if (gap > 0) { // it shows "+N" or is blank) opens that day, same as the app.
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT)) Box(
gap = 0 modifier = GlanceModifier
} .width(colW)
.height(LANE_HEIGHT)
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.CenterStart,
) {
if (hidden > 0) {
Text( Text(
text = "+$count", text = "+$hidden",
maxLines = 1, maxLines = 1,
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp), style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
modifier = GlanceModifier.width(colW).padding(start = 3.dp), modifier = GlanceModifier.padding(start = 3.dp),
) )
} }
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT)) }
}
} }
} }

Some files were not shown because too many files have changed in this diff Show More