Compare commits

..
Author SHA1 Message Date
renovate-bot 8468f3edcc chore(deps): update agp to v9.3.1 2026-08-17 05:03:06 +00:00
364 changed files with 4196 additions and 22015 deletions
+2 -2
View File
@@ -21,9 +21,9 @@ labels:
- Calendula version: <!-- Settings → bottom of the screen --> - Calendula version: <!-- Settings → bottom of the screen -->
- Android version: - Android version:
- Device: - Device:
- Installed from: <!-- official F-Droid / the self-hosted repo / Google Play / Codeberg APK or Obtainium / built from source --> - Installed from: <!-- official F-Droid / the self-hosted repo / built from source -->
- Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local, - Affected calendar: <!-- Google, CalDAV (DAVx5, Nextcloud, …), on-device/local,
subscribed/WebCal, birthdays. Provider behaviour differs subscribed/WebCal, birthdays — provider behaviour differs
a lot per account type, so this often points straight at a lot per account type, so this often points straight at
the cause --> the cause -->
- Time zone: <!-- only if the problem involves dates or all-day events --> - Time zone: <!-- only if the problem involves dates or all-day events -->
+2 -2
View File
@@ -6,8 +6,8 @@ contact_links:
- name: Translate Calendula - name: Translate Calendula
url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/ url: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
about: >- about: >-
Translations are managed on Weblate. It owns every values-* file, so a Translations are managed on Weblate, not here — it owns every values-*
hand-edited translation gets overwritten on the next sync. file, so a hand-edited translation gets overwritten on the next sync.
No coding needed: pick or request a language and translate in the browser. No coding needed: pick or request a language and translate in the browser.
- name: Contributing guide - name: Contributing guide
+3 -3
View File
@@ -1,6 +1,6 @@
--- ---
name: Crash report name: Crash report
about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash). It copies the report to your clipboard and prefills this form. about: Report a crash. Calendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
title: "Crash: " title: "Crash: "
labels: labels:
- bug - bug
@@ -11,10 +11,10 @@ labels:
<!-- <!--
Thanks for reporting a crash in Calendula! Thanks for reporting a crash in Calendula!
If the app prefilled this for you, the crash report is already below. Add If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard what you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the into the code block. The report contains only app/Android/device versions and the
stack trace, with no personal data or calendar content. stack trace — no personal data or calendar content.
--> -->
### What happened ### What happened
+1 -1
View File
@@ -9,7 +9,7 @@ labels:
### What would you like Calendula to do? ### What would you like Calendula to do?
### What problem does it solve? ### Why — what problem does it solve?
### Anything else ### Anything else
+3 -3
View File
@@ -5,7 +5,7 @@ Please skim CONTRIBUTING.md if you haven't:
https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md https://codeberg.org/jlmakiola/calendula/src/branch/main/CONTRIBUTING.md
Two things it's easy to get wrong: Two things it's easy to get wrong:
• Features need a discussed issue first. An undiscussed feature PR may be • Features need a discussed issue first — an undiscussed feature PR may be
closed unmerged even when the code is good. closed unmerged even when the code is good.
• Target the release branch for your issue's milestone (milestone 2.18.0 → • Target the release branch for your issue's milestone (milestone 2.18.0 →
release/v2.18.0), not main. If you targeted main, just say so below and it release/v2.18.0), not main. If you targeted main, just say so below and it
@@ -17,7 +17,7 @@ Two things it's easy to get wrong:
### Why ### Why
<!-- Closes #123: link the issue this implements or fixes. --> <!-- Closes #123 — link the issue this implements or fixes. -->
### How it was tested ### How it was tested
@@ -34,7 +34,7 @@ are especially useful for UI changes.
### Checklist ### Checklist
- [ ] There's an issue for this, and (for a feature) it got a go-ahead - [ ] There's an issue for this, and (for a feature) it got a go-ahead
- [ ] Targeting the release branch for that issue's milestone (or `main`, noted above) - [ ] Targeting the release branch for that issue's milestone — or `main`, noted above
- [ ] `./gradlew lint test assembleDebug` passes locally - [ ] `./gradlew lint test assembleDebug` passes locally
- [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine) - [ ] No `values-*/strings.xml` touched (Weblate owns those; new English strings in `values/` are fine)
- [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible - [ ] `CHANGELOG.md` updated under `## [Unreleased]`, if the change is user-visible
-19
View File
@@ -37,25 +37,6 @@ jobs:
- name: Reproducible-release invariant - name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh run: bash scripts/check_reproducible_release.sh
# Play rejects a "What's New" over 500 characters, which would fail the
# upload after the release had already shipped everywhere else. Cheap, so
# it runs on every PR rather than only on the release merge.
- name: Changelog length invariant
run: bash scripts/check_changelog_lengths.sh
# The whole listing goes to Play with every release; a field over its cap
# or an image Play rejects would fail that upload.
- name: Store listing invariant
run: |
if ! command -v python3 >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y python3
elif command -v apk >/dev/null 2>&1; then
apk add --no-cache python3
fi
fi
python3 scripts/check_store_listing.py
# Decide whether anything that affects the app build changed. Docs, store # Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip # metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`. # the SDK + Gradle work below but still report a green `ci`.
-1
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
+6 -5
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.
-375
View File
@@ -7,340 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- **Pages can open the split agenda.** Swipe up from a full month to reveal the compact grid and month agenda; tap a day to filter it, then swipe down to return. The transition gives haptic feedback ([#111]).
- **Week numbers open their week.** Tap a week number in the month grid to open Week at that row; Back returns to Month ([#197]).
### Changed
- **Split view opens with the month's agenda.** It starts at today in the current month; tapping a day filters the list, and the filter chip restores the full month. Swiping the list moves by day when filtered and by month otherwise ([#142]).
- **Today stands out in the month grid.** Its whole cell has a distinct background; tapping Today briefly expands that cell and nudges its neighbours aside ([#241]).
- **Store listing in every app language.** Google Play and F-Droid show the description in all 16 languages the app ships, and each release publishes the Play listing from the repository ([#285]).
### Fixed
- **Multi-day events stay above shorter events.** Month and Week bars, Split day lists, and Agenda now place multi-day events before one-day all-day events, then timed events; declined events remain last ([#291]).
## [2.21.0] — 2026-09-23
### Added
- **Setup makes sure you have a calendar to write to.** A fresh install could
finish onboarding and land on an empty grid with nothing saying why — either
because the device had no calendar at all, or because every calendar it had
was switched off. Setup now covers both: it offers to create a calendar on
this device or to add an account that syncs one, and it lists the calendars
that exist but are switched off so you can turn them back on ([#287]).
### Changed
- **The month grid gives its days more room.** Week numbers are set smaller and
their gutter is measured from the widest number it has to seat instead of
holding a fixed 40dp; event chips spend less of their width on padding; and
the labels in both are tracked more tightly, which is worth roughly a
character per chip ([#189], [#190], [#212], [#213]).
- **A month day shows as many events as it has height for.** The limit was a
flat three at every size, so a tall week row threw away space it could have
filled, while a cramped one drew a third chip its band could not hold and put
the "more" marker below the clip, where nothing showed the day held more at
all. It is measured per week row now, and a tablet or a landscape phone seats
more of them.
- **Split view no longer pads a short month out to six rows.** A five-row month
was handing the day pane a blank row and its gap — a sixth of the grid. The
grid now draws only the rows its month spans and the pane grows into the rest
([#162]).
### Fixed
- **Short events show their title in week and day view.** A block needed 20dp
of height before it drew any title at all, which at fit-the-whole-day put the
cutoff around the half hour. The padding now tapers as the block shrinks, so
a short event keeps its name ([#289]).
- **A fully zoomed-out timeline sits still.** Rounding its height up left it as
much as a pixel per hour taller than the viewport those hours are meant to
fill — some 24px of leftover scroll, enough to bounce off Android's
overscroll stretch ([#290]).
- **Backup & restore says what is going on instead of coming up blank.** It
built its rows straight from the calendar list, so loading, a provider error,
and a device with nothing to export all rendered as an empty page with no
buttons on it ([#304]).
- **An event with no title reads in your language.** It was labelled "(Ohne
Titel)" on every screen but the event's own, whatever language the phone was
set to: the German text was chosen before the translated one could be
([#321]).
- **A calendar the system gives no name reads in your language.** The same
cause, in the calendar lists and pickers — and because the calendar editor
starts from that name, saving such a calendar wrote the German text into it
for real ([#329]).
## [2.20.4] — 2026-09-20
### Fixed
- **The app no longer reopens the event you last came in on.** Opening an event
from a reminder or a home-screen widget made the app return to that same
event on every later launch — and show an error screen once the event had
been deleted. Android hands an app the launch it was started with again each
time it is rebuilt, and Calendula was acting on it every time instead of
once ([#309]).
- **"Today" works in the agenda again.** Scrolling a few days ahead and tapping
today did nothing, because the agenda is browsed by scrolling rather than by
moving its date, and the button only moved the date. It now brings the list
back to today, and it stays offered while the list is scrolled away — before,
it was hidden in exactly the situation it is for ([#305]).
- **Calendula is filed under "Calendar & Agenda" in the self-hosted repo.**
Browsing by category in an F-Droid client set to prefer that repo listed the
app under "Time" instead ([#294]).
## [2.20.3] — 2026-09-12
### Fixed
- **A custom agenda range crashed the app in French.** Choosing a custom range
for the agenda — or simply typing a number into that field — closed the app on
any phone set to French. The French translation of the "%d days" label was
missing the wording French uses for counts of two and above, and Android has
nothing to fall back on when that particular form is absent, so every value
except one day was affected ([#297], [#298]).
## [2.20.2] — 2026-09-12
### Fixed
- **Crash reports are filed in English.** Reporting a crash from a phone set to
another language prefilled the issue — title and body — in that language, so
a French crash arrived on Codeberg as a French issue ([#297], [#298]). The
text that is *posted* is now English for everyone; the dialog that asks you
to send it, and everything else you see in the app, is still translated.
## [2.20.1] — 2026-09-07
### Fixed
- **An empty calendar now explains itself.** "No calendars configured" offered
an "Open system calendar settings" button that opened nothing — every screen
wired it to the same reload that had just produced the empty state. It now
opens the system's account chooser, filtered to the providers that sync
calendars ([#239]).
- **Calendars that are all switched off no longer look like an empty month.**
When every calendar on the device is hidden at system level there is nothing
to draw, but the app said nothing at all: it only ever checked whether the
device had *no* calendars, so a device that had them and hid them fell
through to a blank grid with no hint. That case now says so, and points at
Settings → Calendars — the one screen that can switch a calendar back on. The
navigation drawer's calendar list drew the same conflation and is fixed with
it ([#239]).
## [2.20.0] — 2026-09-07
### Added
- **Long-press to copy.** Holding down the title, the location or the
description on an event puts that whole field on the clipboard. Tapping still
does what it always did — the location opens maps, a link in the description
opens — and the times, calendar, repeat rule and guests stay uncopyable. Below
Android 13 a short confirmation appears; from Android 13 on the system shows
its own clipboard chip ([#195]).
- **The open-source projects Calendula ships** are now listed under Settings →
About, each with its copyright holder and licence, and each row tapping
through to that project's source ([#281]).
### Changed
- 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 in the month, week and day views — a few
more letters per event, which is often the difference between two events you
can tell apart and two you can't. Right-to-left languages keep the ellipsis,
where clipping would cut the beginning of a title rather than its end
([#164]).
- Month-view events now show their start time before the title, where the chip
is wide enough to keep the title readable — in landscape, on a foldable and on
a tablet, and never at the cost of a narrow phone column. All-day events show
no time, and neither does a bar carried in from the previous week, whose start
is not in that row. Event times across the month, week and day views are also
set in a regular weight against the title's, so a time reads as a time rather
than as part of the name next to it ([#219]).
- **The hour lines in week and day view are now an hour grid.** The setting
that drew a faint separator line at each hour now seats every hour in its own
rounded cell, so an hour boundary reads as the seam between two surfaces
rather than a line drawn across one — the same negative space that separates
the month grid's days and the week's columns. An event that starts on the hour
fills its cell instead of overhanging the seam, and one that runs past
midnight still meets the edge of its column ([#113]).
- **An invitation you haven't answered now reads as an outline.** An event still
waiting on your reply is drawn with the calendar's colour on its border and
its title and nothing behind it, so it stands apart from the ones you have
accepted — on the month chips, the all-day bars and the week and day blocks
alike. Declined events keep their filled, struck-through chip and now sort
last in the month view as well, so a day with more events than fit drops the
ones you said no to first ([#230]).
- 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]).
- The jump-to-today button in the toolbar now shows today's date. It drew a
generic calendar icon, which told you nothing you didn't already know from
tapping it; it now carries the current day number in an outlined box, so the
bar says what day it is as well as taking you there. It rolls over at midnight
on its own ([#220]).
- The quick-switch pill disappears when there is nothing left to switch between,
and you can now turn calendar views off all the way down to a single one — the
setting used to hold two open purely to keep the pill fed ([#150]).
### Fixed
- **A long location no longer runs off the edge of its field.** The location on
the edit screen sat on one line, so a full postal address or a long meeting
link scrolled sideways out of sight as you typed it. It now wraps and the card
grows to fit, with the pin and the contacts button staying level with the first
line at any font size. A multi-line address you paste in is joined with commas
— the way one picked from your contacts always has been — instead of running
together into a single word ([#273]).
- **The event form capitalizes sentences again.** The title, description and
guest fields had stopped starting a sentence with a capital letter; they
follow the keyboard's usual behaviour once more. The location is deliberately
left alone, since it is as often a URL as an address ([#146]).
- **A block you pick up no longer loses its second line.** Dragging an event
whose time or title wrapped to two lines clipped the floating copy to one line
for as long as you held it, and it snapped back on drop. The copy now measures
its text exactly as the block does ([#267]).
## [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 ## [2.19.1] — 2026-08-11
### Added ### Added
@@ -1748,44 +1414,3 @@ automatically, with zero telemetry and no internet permission.
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123 [#123]: https://codeberg.org/jlmakiola/calendula/issues/123
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163 [#163]: https://codeberg.org/jlmakiola/calendula/issues/163
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173 [#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
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220
[#195]: https://codeberg.org/jlmakiola/calendula/issues/195
[#281]: https://codeberg.org/jlmakiola/calendula/issues/281
[#230]: https://codeberg.org/jlmakiola/calendula/issues/230
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
[#297]: https://codeberg.org/jlmakiola/calendula/issues/297
[#298]: https://codeberg.org/jlmakiola/calendula/issues/298
[#309]: https://codeberg.org/jlmakiola/calendula/issues/309
[#305]: https://codeberg.org/jlmakiola/calendula/issues/305
[#294]: https://codeberg.org/jlmakiola/calendula/issues/294
[#287]: https://codeberg.org/jlmakiola/calendula/issues/287
[#189]: https://codeberg.org/jlmakiola/calendula/issues/189
[#190]: https://codeberg.org/jlmakiola/calendula/issues/190
[#212]: https://codeberg.org/jlmakiola/calendula/issues/212
[#213]: https://codeberg.org/jlmakiola/calendula/issues/213
[#162]: https://codeberg.org/jlmakiola/calendula/issues/162
[#289]: https://codeberg.org/jlmakiola/calendula/issues/289
[#290]: https://codeberg.org/jlmakiola/calendula/issues/290
[#304]: https://codeberg.org/jlmakiola/calendula/issues/304
[#321]: https://codeberg.org/jlmakiola/calendula/issues/321
[#329]: https://codeberg.org/jlmakiola/calendula/issues/329
[#285]: https://codeberg.org/jlmakiola/calendula/issues/285
+29 -29
View File
@@ -1,13 +1,13 @@
# Contributing to Calendula # Contributing to Calendula
Calendula is a Material 3 Expressive calendar app that lives strictly on top of Calendula is a Material 3 Expressive calendar app that lives strictly on top of
Android's `CalendarContract`, with no app database, sync stack or network access. Android's `CalendarContract` — no app database, no sync stack, no network access.
That constraint shapes most review comments, so skim That constraint shapes most review comments, so
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) before you write code. This file [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) is worth skimming before you write
covers the practical side. code. This file is the practical how.
[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home for **[Codeberg](https://codeberg.org/jlmakiola/calendula) is the canonical home** —
issues, pull requests and releases. The self-hosted Gitea instance referenced in the issues, pull requests, releases. The self-hosted Gitea instance referenced in the
release docs is build infrastructure only; there is nothing to contribute there. release docs is build infrastructure only; there is nothing to contribute there.
Be decent to the people you meet in the tracker. Be decent to the people you meet in the tracker.
@@ -18,20 +18,20 @@ Be decent to the people you meet in the tracker.
| Add a feature | **Open an issue first** and wait for a go-ahead | | Add a feature | **Open an issue first** and wait for a go-ahead |
| Fix a bug | Open an issue, then a pull request | | Fix a bug | Open an issue, then a pull request |
| Fix a typo, a comment, or docs | Just open the pull request | | Fix a typo, a comment, or docs | Just open the pull request |
| Add or fix a translation | Use [Weblate](#translations), not a pull request | | Add or fix a translation | **Don't** — [use Weblate](#translations) |
Features get an opinion before they get code: whether Calendula should do a Features get an opinion before they get code: whether Calendula should do a
thing at all is the one decision a patch can't make. A feature PR that arrives thing at all is the one decision a patch can't make. A feature PR that arrives
without a discussed issue may be closed unmerged even when the code is good, so without a discussed issue may be closed unmerged even when the code is good —
please don't spend a weekend on one first. please don't spend a weekend on one first.
Bugs are more straightforward, but still start with an issue: it's what carries Bugs are more straightforward, but still start with an issue: it's what carries
the milestone and gives the changelog something to link. the milestone and gives the changelog something to link.
Issue templates cover bug, crash, feature and question. For a crash, let the app Issue templates cover bug, crash, feature and question. For a crash, let the app
do the work: *Settings → Report a problem*, or the prompt shown after a crash, do the work — **Settings → Report a problem**, or the prompt shown after a crash,
captures the stack trace and prefills the form. The report contains app, Android captures the stack trace and prefills the form. The report contains app, Android
and device versions plus the trace, and no calendar content or personal data. and device versions plus the trace; no calendar content, no personal data.
## Which branch to target ## Which branch to target
@@ -45,20 +45,20 @@ Once your issue has a milestone, that milestone names your branch:
| `2.18.0` | `release/v2.18.0` | | `2.18.0` | `release/v2.18.0` |
Every milestone has a matching branch. If it's somehow missing, target `main` and Every milestone has a matching branch. If it's somehow missing, target `main` and
mention it in the PR, and it will be retargeted. Don't pick an older release branch: mention it in the PR — it will be retargeted. Don't pick an older release branch:
they're kept after shipping, so the newest one isn't necessarily yours. they're kept after shipping, so the newest one isn't necessarily yours.
## Translations ## Translations
**Never edit a `values-*/strings.xml` file in a pull request**, German included. **Never edit a `values-*/strings.xml` file in a pull request** — German included.
Translations are owned by a self-hosted Weblate that writes to this repository Translations are owned by a self-hosted Weblate that writes to this repository
directly, and a hand-edit is overwritten on the next sync. directly, and a hand-edit is overwritten on the next sync.
[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/) → **[Translate Calendula on Weblate](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate Adding a *new* English string to `values/strings.xml` is normal PR work; Weblate
picks it up and offers it to translators. Partial translations are expected and picks it up and offers it to translators. Partial translations are expected and
fine; missing keys are informational. Stale and orphaned keys are not, so run fine — missing keys are informational. Stale and orphaned keys are not, so run
```sh ```sh
python3 scripts/check_translations.py python3 scripts/check_translations.py
@@ -67,7 +67,7 @@ python3 scripts/check_translations.py
before pushing. It reports those more clearly than lint's `MissingTranslation` before pushing. It reports those more clearly than lint's `MissingTranslation`
does. does.
## Build and test ## Build & test
```sh ```sh
git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git git clone --recurse-submodules https://codeberg.org/jlmakiola/calendula.git
@@ -77,11 +77,11 @@ The `floret-kit` submodule is a composite build compiled from source. An
existing clone needs `git submodule update --init --recursive`, or nothing existing clone needs `git submodule update --init --recursive`, or nothing
resolves. resolves.
- **JDK 17**, not newer: the Android Gradle Plugin requires exactly 17. Set - **JDK 17** — not newer; the Android Gradle Plugin requires exactly 17. Set
`JAVA_HOME` if your default differs. `JAVA_HOME` if your default differs.
- **Android SDK**: platform 37 (`compileSdk`) and build-tools 36.0.0, located - **Android SDK** — platform 37 (`compileSdk`) and build-tools 36.0.0, located
via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you via `ANDROID_HOME` or a gitignored `local.properties` with `sdk.dir`. If you
go the `local.properties` route, the included build needs its own copy at go the `local.properties` route the included build needs its own copy at
`floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the `floret-kit/local.properties`; `ANDROID_HOME` covers both at once and is the
easier path. easier path.
@@ -109,21 +109,21 @@ These are the ones that turn into review comments.
1. **No network.** Calendula holds no `INTERNET` permission, and that's a 1. **No network.** Calendula holds no `INTERNET` permission, and that's a
feature rather than an oversight. Anything that would need one is a product feature rather than an oversight. Anything that would need one is a product
decision before it's a patch. The crash reporter opens a prefilled web issue decision before it's a patch — the crash reporter deliberately opens a
on purpose instead of posting anything itself. prefilled web issue instead of posting anything itself.
2. **The provider is the only database.** No Room, no cache, no local mirror of 2. **The provider is the only database.** No Room, no cache, no local mirror of
events. `CalendarContract` is the single source of truth, which is also why events. `CalendarContract` is the single source of truth, which is also why
externally synced changes work for free. externally synced changes work for free.
3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and 3. **Don't patch UI state after a write.** A `ContentObserver` re-queries and
views recompose from fresh provider state. Hand-patching a list after saving views recompose from fresh provider state. Hand-patching a list after saving
appears to work, then drifts away from what the provider stored. appears to work, then quietly diverges from what the provider actually stored.
4. **`domain/` has no Android imports.** Models, validation, recurrence 4. **`domain/` has no Android imports.** Models, validation, recurrence
rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they rendering, conflict snapshots and the `.ics` codec stay pure Kotlin so they
remain JVM-testable. remain JVM-testable.
5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you: 5. **Tests run on the JVM.** JUnit 5 + Truth + Turbine. The seams exist for you:
fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps fake the data source (`FakeCalendarDataSource`), and feed mappers plain maps
through `ColumnReader` instead of cursors. Instrumented tests are a last through `ColumnReader` instead of cursors. Instrumented tests are a last
resort. resort, not a default.
6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs 6. **Read before touching the subtle pipelines.** Recurring writes (UNTIL vs
DURATION, exception URIs, series splits), save-conflict detection and reminder DURATION, exception URIs, series splits), save-conflict detection and reminder
delivery (post-before-mark) follow provider-driven rules that are documented delivery (post-before-mark) follow provider-driven rules that are documented
@@ -138,14 +138,14 @@ These are the ones that turn into review comments.
## UI conventions ## UI conventions
Material 3 Expressive throughout, built from the system's own tokens and Material 3 Expressive throughout, built from the system's own tokens and
components: colour-scheme tokens rather than hardcoded colours, `ListItem` for components — colour-scheme tokens rather than hardcoded colours, `ListItem` for
settings rows. settings rows.
**Selection pickers are full-screen.** Every browse-style "choose one" surface **Selection pickers are full-screen.** Every browse-style "choose one" surface
uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or uses floret-kit's `FullScreenPicker` / `OptionPicker`; one that needs a commit or
extra action passes it through the picker's `actions` slot. The exception is the extra action passes it through the picker's `actions` slot. The exception is the
recurring-scope chooser (*this / this and following / all*), which stays a recurring-scope chooser (*this / this and following / all*), which stays a
compact dialog, because a two- or three-option decision reads better as a popup than as compact dialog — a two- or three-option decision reads better as a popup than as
a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio- a nearly empty screen. `AlertDialog` is for plain confirmations only, and radio-
or text-list dialogs aren't used at all. or text-list dialogs aren't used at all.
@@ -154,7 +154,7 @@ Shared UI machinery lives in the `floret-kit` submodule and has
changing it means a pull request against that repository plus a submodule bump changing it means a pull request against that repository plus a submodule bump
here. here.
## Commits and pull requests ## Commits & pull requests
Conventional commits, scoped to the area you touched: Conventional commits, scoped to the area you touched:
@@ -165,13 +165,13 @@ docs(architecture): record what the second review pass changed
``` ```
Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build` Types in use: `feat` `fix` `docs` `refactor` `style` `chore` `ci` `build`
`revert`. Reference the issue in the subject or the body. Keep commits small. `revert`. Reference the issue in the subject or the body. Keep commits small —
Small commits revert cleanly, which matters more here than a tidy history. small commits revert cleanly, which matters more here than a tidy history.
If your change is user-visible, add an entry under `## [Unreleased]` in If your change is user-visible, add an entry under `## [Unreleased]` in
[`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe [`CHANGELOG.md`](CHANGELOG.md). Match the surrounding voice: entries describe
what changed *for the person using the app*, and why. Code changes don't go what changed *for the person using the app*, and why, not what changed in the
there. Link the issue and add its reference at the bottom of the file. It may get code. Link the issue and add its reference at the bottom of the file. It may get
reworded when the release is cut, so don't agonise over it. reworded when the release is cut, so don't agonise over it.
Please don't commit planning or design documents. Code, tests, architecture notes Please don't commit planning or design documents. Code, tests, architecture notes
+106 -119
View File
@@ -4,8 +4,8 @@
<h1>Calendula</h1> <h1>Calendula</h1>
<p><strong>A Material 3 Expressive calendar for Android.</strong><br> <p><strong>A modern Material 3 Expressive calendar for Android.</strong><br>
It works on top of the system calendar and has no network access.</p> Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
<p> <p>
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a> <a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
@@ -18,97 +18,90 @@ It works on top of the system calendar and has no network access.</p>
<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>
</p> </p>
<p> <p>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="13%" alt="Week view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01-week.png" width="16%" alt="Week view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="13%" alt="Month view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02-month.png" width="16%" alt="Month view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-agenda.png" width="13%" alt="Agenda view">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03-day.png" width="16%" alt="Day view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="13%" alt="Event detail">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04-detail.png" width="16%" alt="Event detail">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-recurring.png" width="13%" alt="Editing a recurring event">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05-agenda.png" width="16%" alt="Agenda view">&nbsp;
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-calendars.png" width="13%" alt="Calendars from your accounts">&nbsp; <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06-onboarding.png" width="16%" alt="Reminder onboarding">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/07-dark.png" width="13%" alt="Dark theme">
</p> </p>
</div> </div>
Calendula is named after the flower. Both its name and the word *calendar* Calendula is named after the flower whose name — like the word *calendar* —
come from the Latin *kalendae*, the first day of the month. comes from the Latin *kalendae*, the first day of the month. It lives
entirely on top of Android's `CalendarContract`: any calendar synced to your
device (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply
appears, and everything you create or edit syncs back the same way. No own
database, no sync stack reinvented.
The app works directly on Android's `CalendarContract`. Any calendar synced to ## ✨ Features
your device (CalDAV via DAVx5, Google, local, WebCal subscriptions) shows up in
Calendula, and whatever you create or edit syncs back the same way. Calendula
has no database or sync code of its own.
## Features **Calendar**
### Calendar - Month, week, and day views with a one-tap view switcher
- Full event details — attendees and their responses, reminders, recurrence
(humanized), availability, visibility, foreign time zones
- Per-calendar visibility toggle, grouped by account
- Month, week and day views, with a switcher in the top bar **Editing**
- Event details with attendees and their responses, reminders, recurrence in
plain words, availability, visibility and foreign time zones
- Calendars can be shown or hidden one by one, grouped by account
- Your contacts' birthdays, anniversaries and other dates, if you turn it on.
Contacts are read on the device and never changed.
### Editing - Create, edit, and delete events — including recurring events with scoped
writes: *only this event*, *this and all following*, or *the whole series*
- Recurrence picker with one-tap presets and custom rules (interval, weekday
toggles, end conditions); rules it can't express are preserved verbatim
- Conflict-safe saves: if an event changed elsewhere while you were editing,
Calendula asks instead of silently overwriting
- Read-only calendars (WebCal, birthdays) are detected and respected
- Create, edit and delete events. For a recurring event you choose whether a **Reminders**
change applies to *only this event*, *this and all following*, or *the whole
series*.
- The repeat picker has presets and custom rules (interval, weekdays, end
condition). Rules it can't show are kept as they are.
- If an event changed elsewhere while you were editing it, Calendula asks
before saving over it.
- Read-only calendars such as WebCal subscriptions and birthdays are detected
and can't be edited by accident.
### Reminders - Event reminders delivered by Calendula itself as notifications —
essential when it's your only calendar app, since Android delegates
reminder delivery to calendar apps
- Tap a reminder to land on the event
- Calendula shows event reminders as notifications itself. Android leaves **Design & privacy**
reminder delivery to calendar apps, so if Calendula is your only one, this is
where your reminders come from.
- Tapping a reminder opens the event.
### Design and privacy - Real Material 3 Expressive throughout — dynamic color (Android 12+),
expressive motion and shapes, light/dark theme
- English and German UI plus community translations (Spanish, French, Italian,
Polish, and more in progress), per-app language setting — and [open to more
languages](#-translations)
- **Zero telemetry, zero analytics, no internet permission** — your data
never leaves the device
- Material 3 Expressive throughout, with dynamic color on Android 12+, light ## 📦 Install
and dark themes, and expressive motion and shapes
- Available in 16 languages, with a per-app language setting.
[Help translate](#translations).
- No telemetry, no analytics and no internet permission. Your data stays on
the device.
## Install Pick whichever channel you already use — they all install the same app:
All channels install the same app:
| Channel | Updates | Notes | | Channel | Updates | Notes |
| --- | --- | --- | | --- | --- | --- |
| [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; add the repo 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 automatic with 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)
Calendula is in the official [F-Droid](https://f-droid.org) repository. Search Calendula is on the **official [F-Droid](https://f-droid.org) repository** —
for Calendula in any F-Droid client, or just search for **Calendula** in any F-Droid client, or
[install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/). [install it from f-droid.org](https://f-droid.org/packages/de.jeanlucmakiola.calendula/).
F-Droid builds from source on its own schedule, so a new version usually shows F-Droid rebuilds from source on its own schedule, so a new version usually
up there a few days after release. shows up there a few days after release.
### Self-hosted F-Droid repo (fastest updates) ### Self-hosted F-Droid repo (fastest updates)
The release pipeline builds, signs and publishes every release to a Every release is built, signed, and published to a self-hosted F-Droid
self-hosted F-Droid repository, so new versions land there first. Add it once repository as part of the release pipeline, so it lands there first. Add it once
and your F-Droid client handles updates from then on: and your F-Droid client handles updates from then on:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the 1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
@@ -122,86 +115,80 @@ and your F-Droid client handles updates from then on:
fingerprint (SHA-256): fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub> `C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
2. Refresh, search for Calendula and install it. 2. Refresh, search for **Calendula**, install.
### Codeberg release / Obtainium ### Codeberg release / Obtainium
Every release is also on If you'd rather not use F-Droid at all, every release is also published on
[Codeberg](https://codeberg.org/jlmakiola/calendula/releases) with the signed **[Codeberg](https://codeberg.org/jlmakiola/calendula/releases)** with the
APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum. Download it and install signed APK (`calendula_vX.Y.Z.apk`) and a `.sha256` checksum attached — download
it directly. and install it directly.
For automatic updates from Codeberg, use For automatic updates from that channel, use
[Obtainium](https://github.com/ImranR98/Obtainium). On your phone, **[Obtainium](https://github.com/ImranR98/Obtainium)** — on the phone,
[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula), **[add Calendula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/calendula)**,
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/calendula`, or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
then *Add*. Obtainium then watches the releases and tells you when a new one → *Add*. Either way, Obtainium tracks the releases and prompts you when a new
is out. one appears.
### Google Play ### Google Play (coming soon)
Calendula is on Calendula is on its way to Google Play as an additional channel. It isn't live
[Google Play](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 means uninstalling first. You lose the app settings. > go public, and I'm still looking for testers. If you'd like to help, email
Your events live in the system calendar and aren't affected. > **[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
It's a plain Gradle build with no proprietary dependencies. See The build is a plain Gradle build with no proprietary dependencies — see
[docs/BUILDING.md](docs/BUILDING.md), and note the `floret-kit` submodule. **[docs/BUILDING.md](docs/BUILDING.md)** (note the `floret-kit` submodule).
<sub>Official F-Droid, the self-hosted repo and the Codeberg releases share the <sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
same signing key, so you can switch between them without reinstalling. Google the same signing key, so you can switch freely between them without
Play is the exception (see above).</sub> reinstalling.</sub>
## Documentation ## 📚 Documentation
- [Contributing](CONTRIBUTING.md): how to report bugs, propose features and send patches - **[Contributing](CONTRIBUTING.md)** — how to report, propose, and patch
- [Building from source](docs/BUILDING.md): requirements and Gradle tasks - **[Building from source](docs/BUILDING.md)** — requirements and Gradle tasks
- [Architecture](docs/ARCHITECTURE.md): the layers and the main pipelines - **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
- [Milestones](https://codeberg.org/jlmakiola/calendula/milestones): what has shipped and what's next - **[Milestones](https://codeberg.org/jlmakiola/calendula/milestones)** — what's shipped and what's next
- [Privacy policy](docs/PRIVACY.md): the source of the page at
[jeanlucmakiola.de/calendula/privacy](https://jeanlucmakiola.de/calendula/privacy)
## Contributing ## 🤝 Contributing
Bug reports, ideas and patches are welcome on Bug reports, ideas, and patches are all welcome on
[Codeberg](https://codeberg.org/jlmakiola/calendula/issues). **[Codeberg](https://codeberg.org/jlmakiola/calendula/issues)**.
Start with an issue. A feature gets a yes or no before anyone writes code, and The short version: **start with an issue.** Features get a yes-or-no before they
features and bugs are assigned a milestone whose `release/vX.Y.Z` branch your get code, and both features and bugs are assigned a milestone whose
pull request targets. Typo and docs fixes can go straight to a pull request. `release/vX.Y.Z` branch your pull request then targets. Typo and docs fixes can
Translations go through [Weblate](#translations), not pull requests. skip straight to a pull request. Translations don't go through pull requests at
all — [Weblate owns them](#-translations).
Read [CONTRIBUTING.md](CONTRIBUTING.md) before writing code. It covers the Read **[CONTRIBUTING.md](CONTRIBUTING.md)** before writing code: it covers the
workflow, the build (including the `floret-kit` submodule) and the workflow, the build (note the `floret-kit` submodule), and the architectural
architectural rules changes are reviewed against. rules a change is reviewed against.
## Translations ## 🌍 Translations
Calendula is written in English. Translators on Weblate have added Arabic, Calendula ships in English and German, with community translations in Arabic,
Belarusian, Chinese (Simplified), Czech, French, German, Hungarian, Italian, Chinese, French, Italian, Polish, Portuguese, Russian, and Spanish at varying
Japanese, Polish, Portuguese (Portugal and Brazil), Russian, Slovak and Spanish, degrees of completeness — partial is fine, untranslated strings simply fall back
some of them partly. Untranslated strings fall back to English, so a partial to English. You're warmly invited to add or finish your language. Translations
translation is fine. Translations are managed on a self-hosted Weblate: are managed on a self-hosted **Weblate**:
[Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/) **→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
You don't need to code. Register on the Weblate server, pick a language (or No coding needed — register on the Weblate server, pick (or request) a language,
request one) and translate in your browser. The app links here from the top of and translate the strings in your browser. You can also reach this link in the
*Settings → App language*. app from the top of **Settings → App language**.
## Privacy ## 📜 License
Calendula has no analytics, tracking, ads, crash-reporting SDK or third-party [MIT](LICENSE) — Jean-Luc Makiola, 2026
services. It doesn't have the internet permission, so it can't send anything
anywhere. Your events stay in Android's own calendar store.
[Privacy policy](https://jeanlucmakiola.de/calendula/privacy), written in
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
## License
[MIT](LICENSE), Jean-Luc Makiola, 2026
+8 -12
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 = 22100 versionCode = 21901
versionName = "2.21.0" versionName = "2.19.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -113,16 +113,12 @@ android {
lint { lint {
// Community translations are expected to be partial — a missing string // Community translations are expected to be partial — a missing string
// falls back to the English base at runtime — so don't fail the build on // falls back to the English base at runtime — so don't fail the build on
// it. A translated <plurals> may likewise skip a CLDR quantity form its // it. Likewise a translated <plurals> may not fill every CLDR quantity
// locale defines (e.g. Arabic "zero"): Android falls back to "other" for // form its locale defines (e.g. Arabic needs "zero"); the missing form
// any form it cannot find, so MissingQuantity is informational too. // falls back to "other" at runtime, so MissingQuantity is informational
// What a translation must NOT skip is "other" itself — nothing falls back // too. Stale/extra keys (ExtraTranslation) stay fatal; scripts/
// for that one, not even the base locale, so it throws // check_translations.py guards the same invariants with clearer,
// Resources$NotFoundException at runtime (Codeberg #297/#298). // translator-facing messages.
// MissingQuantity doesn't tell the two cases apart, so that invariant is
// enforced by scripts/check_translations.py instead, with a clearer
// translator-facing message. Stale/extra keys (ExtraTranslation) stay
// fatal.
informational += listOf("MissingTranslation", "MissingQuantity") informational += listOf("MissingTranslation", "MissingQuantity")
} }
-6
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
+3 -9
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>
@@ -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
@@ -32,34 +31,19 @@ class CalendulaApp : Application() {
// respecting, on-device; the user submits the report by hand). The // respecting, on-device; the user submits the report by hand). The
// capture/loop-detection/report machinery lives in floret-kit's // capture/loop-detection/report machinery lives in floret-kit's
// core-crash; only the app label + issue-tracker URLs are app-specific. // core-crash; only the app label + issue-tracker URLs are app-specific.
// The prefilled issue's own title and body are the kit's, in English:
// they are posted to the tracker, so they don't follow the device
// language the way the dialog around them does.
CrashReporter.install( CrashReporter.install(
this, this,
CrashConfig( CrashConfig(
appLabel = getString(R.string.app_name), appLabel = getString(R.string.app_name),
newIssueUrl = getString(R.string.report_issue_url), newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_choose_url), chooseIssueUrl = getString(R.string.report_issue_choose_url),
issueTitle = getString(R.string.crash_report_issue_title),
), ),
) )
reconcileAutoBackup() reconcileAutoBackup()
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)
}
} }
/** /**
@@ -31,8 +31,7 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalMultiDayCount
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -114,26 +113,11 @@ class MainActivity : AppCompatActivity() {
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES
applyEdgeToEdge() applyEdgeToEdge()
// Android hands the intent that started the task back to onCreate every requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
// time the activity is recreated — on a rotation, or on a restart after
// the system reclaimed the process. Re-running a launch that opens one
// specific event is what made a single reminder or widget tap re-open
// that event on every later launch, an error screen once the event had
// been deleted (#309); the user left it long ago, and the activity
// restores where they actually were along with its saved state.
//
// Only those channels are held back. An .ics import or a prefilled
// create form keeps no saved state of its own, so re-reading the intent
// is what carries one through a recreation.
val replaying = savedInstanceState != null || intent.isRelaunch()
if (!replaying) {
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
requestedEditKey = intent.editEventKeyOrNull()
}
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
?.takeUnless { replaying && it is WidgetNavRequest.OpenEvent }
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
requestedInsert = intent.insertRequestOrNull() requestedInsert = intent.insertRequestOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent { setContent {
// One activity-scoped SettingsViewModel drives both the theme here // One activity-scoped SettingsViewModel drives both the theme here
@@ -170,7 +154,6 @@ class MainActivity : AppCompatActivity() {
// weeks and switching between the week and day view. // weeks and switching between the week and day view.
val timelineZoom = rememberTimelineZoom( val timelineZoom = rememberTimelineZoom(
stored = settings.timelineScale, stored = settings.timelineScale,
hours = settings.timelineHours,
onPersist = settingsViewModel::setTimelineScale, onPersist = settingsViewModel::setTimelineScale,
) )
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle() val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
@@ -188,9 +171,8 @@ class MainActivity : AppCompatActivity() {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour, LocalUse24HourFormat provides use24Hour,
LocalShowHourGrid provides settings.showHourGrid, LocalShowHourLines provides settings.showHourLines,
LocalTimelineZoom provides timelineZoom, LocalTimelineZoom provides timelineZoom,
LocalMultiDayCount provides settings.multiDayCount,
LocalSoftenColors provides settings.softenColors, LocalSoftenColors provides settings.softenColors,
) { ) {
RootScreen( RootScreen(
@@ -260,16 +242,6 @@ class MainActivity : AppCompatActivity() {
intent.editEventKeyOrNull()?.let { requestedEditKey = it } intent.editEventKeyOrNull()?.let { requestedEditKey = it }
} }
/**
* Whether this launch is the task being resumed rather than a fresh
* delivery — Android sets the flag when an activity is started from the
* recents list, where the intent it carries is the one the task was started
* with however long ago. `onNewIntent` is the channel a real new request
* arrives on while the task lives, so nothing is lost by ignoring these.
*/
private fun Intent.isRelaunch(): Boolean =
flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
/** /**
* The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`) * The `.ics` Uri an external app asked us to open (file manager `ACTION_VIEW`)
* or share into us (`ACTION_SEND`). Restricted to content/file schemes so the * or share into us (`ACTION_SEND`). Restricted to content/file schemes so the
@@ -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
}
@@ -1,35 +1,22 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
/** /**
* The colours offered when creating or editing a calendar; ARGB ints for a raw * Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
* `CALENDAR_COLOR` / `EVENT_COLOR`. * `EVENT_COLOR`. The named entries exist for callers that need one specific
* * hue (the managed special-dates calendars), so they can't drift from the
* Hues rather than final fills: every surface that draws a calendar colour runs * swatches offered in the colour picker.
* it through the app's own tone pass first, so these are chosen to stay
* distinguishable after it. Shared with Agendula, whose list palette these are —
* the two apps are the same family and a calendar and a task list picked from
* different-looking sets read as different products.
*
* The named entries exist for callers that need one specific hue (the managed
* special-dates calendars), so they can't drift from the swatches on offer.
*/ */
object CalendarColorPalette { object CalendarColorPalette {
val Mauve = 0xFF7A5C6B.toInt() val Red = 0xFFD50000.toInt()
val Red = 0xFFD7484A.toInt() val Orange = 0xFFE67C00.toInt()
val Orange = 0xFFE8743B.toInt() val Amber = 0xFFF6BF26.toInt()
val Amber = 0xFFE0A32E.toInt() val Green = 0xFF33B679.toInt()
val Olive = 0xFF7CA83E.toInt() val DarkGreen = 0xFF0B8043.toInt()
val Green = 0xFF35A06A.toInt() val Blue = 0xFF039BE5.toInt()
val Teal = 0xFF19938C.toInt() val Indigo = 0xFF3F51B5.toInt()
val Cyan = 0xFF2A9BC4.toInt() val Purple = 0xFF8E24AA.toInt()
val Blue = 0xFF3C74C8.toInt() val Graphite = 0xFF616161.toInt()
val Indigo = 0xFF6A5CC0.toInt()
val Purple = 0xFF9455B8.toInt()
val Pink = 0xFFC94F8E.toInt()
/** The full palette, in swatch-grid order. */ /** The full palette, in swatch-row order. */
val all: List<Int> = listOf( val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
Mauve, Red, Orange, Amber, Olive, Green,
Teal, Cyan, Blue, Indigo, Purple, Pink,
)
} }
@@ -35,7 +35,6 @@ 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
@@ -85,25 +84,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 +101,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 +232,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
@@ -376,7 +348,7 @@ class AndroidCalendarDataSource @Inject constructor(
.build() .build()
override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long { override fun createLocalCalendar(displayName: String, color: Int, description: String?): Long {
val name = displayName.trim() val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply { val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME) put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
@@ -400,7 +372,7 @@ class AndroidCalendarDataSource @Inject constructor(
} }
override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) { override fun updateCalendar(id: Long, displayName: String, color: Int, description: String?) {
val name = displayName.trim() val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply { val values = ContentValues().apply {
put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name) put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, name)
put(CalendarContract.Calendars.NAME, name) put(CalendarContract.Calendars.NAME, name)
@@ -464,7 +436,7 @@ class AndroidCalendarDataSource @Inject constructor(
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long { override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
val name = displayName.trim() val name = displayName.trim().ifEmpty { Fallbacks.UNNAMED_CALENDAR }
val values = ContentValues().apply { val values = ContentValues().apply {
put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME) put(CalendarContract.Calendars.ACCOUNT_NAME, LOCAL_ACCOUNT_NAME)
put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) put(CalendarContract.Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL)
@@ -749,10 +721,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 +739,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 +778,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 +786,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 +976,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 +1191,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 +1212,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 +1228,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 +1284,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 +1296,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 +1311,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 +1502,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 +1515,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,
@@ -8,9 +8,8 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL val isLocal = accountType == CalendarContract.ACCOUNT_TYPE_LOCAL
return CalendarSource( return CalendarSource(
id = getLong(CalendarProjection.IDX_ID), id = getLong(CalendarProjection.IDX_ID),
// Blank when the provider row has no name: the placeholder is a display displayName = getString(CalendarProjection.IDX_DISPLAY_NAME)
// string and belongs to the UI, where it can be localised (#329). ?: Fallbacks.UNNAMED_CALENDAR,
displayName = getString(CalendarProjection.IDX_DISPLAY_NAME).orEmpty(),
accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(), accountName = getString(CalendarProjection.IDX_ACCOUNT_NAME).orEmpty(),
accountType = accountType, accountType = accountType,
color = getInt(CalendarProjection.IDX_COLOR), color = getInt(CalendarProjection.IDX_COLOR),
@@ -88,15 +88,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);
@@ -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
@@ -27,7 +26,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
@@ -212,60 +210,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 +262,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 +288,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 {
@@ -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(',')
CalendarContract.Events.DTSTART to dtStartMillis, ?.map { it.trim() }
CalendarContract.Events.RRULE to rrule, ?.filter { it.isNotEmpty() }
CalendarContract.Events.RDATE to rdate, .orEmpty()
CalendarContract.Events.DURATION to duration, val merged = (existing + stamp).distinct().joinToString(",")
CalendarContract.Events.EVENT_TIMEZONE to timezone, return mapOf(
CalendarContract.Events.ALL_DAY to allDay, CalendarContract.Events.EXDATE to merged,
) CalendarContract.Events.DTSTART to dtStartMillis,
CalendarContract.Events.RRULE to rrule,
/** CalendarContract.Events.DURATION to duration,
* [existingExdate] with the occurrence at [occurrenceMillis] excluded as well. CalendarContract.Events.EVENT_TIMEZONE to timezone,
* CalendarContract.Events.ALL_DAY to allDay,
* 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,
)
/**
* 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 { } else {
local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE) "%04d%02d%02dT%02d%02d%02dZ".format(
utc.year, utc.monthValue, utc.dayOfMonth,
utc.hour, utc.minute, utc.second,
)
} }
/**
* [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
}
@@ -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()
}
@@ -1,10 +1,8 @@
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
import de.jeanlucmakiola.calendula.domain.EventResponse
private const val TAG = "InstanceMapper" private const val TAG = "InstanceMapper"
@@ -21,9 +19,8 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
return null return null
} }
// Left blank when the event has no title: the placeholder is a display val rawTitle = getString(InstanceProjection.IDX_TITLE)
// string, so it belongs to the UI layer where it can be localised (#321). val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) { val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
getInt(InstanceProjection.IDX_CALENDAR_COLOR) getInt(InstanceProjection.IDX_CALENDAR_COLOR)
@@ -41,17 +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),
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
) )
} }
/**
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
* counts as going, and everything that is not an open or refused invitation —
* your own events included — falls through to [EventResponse.Going].
*/
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
else -> EventResponse.Going
}
@@ -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
} }
/** /**
@@ -188,7 +182,6 @@ internal object SearchProjection {
CalendarContract.Events.RDATE, CalendarContract.Events.RDATE,
// Excerpted, not just filtered on: a hit has to show what it matched. // Excerpted, not just filtered on: a hit has to show what it matched.
CalendarContract.Events.DESCRIPTION, CalendarContract.Events.DESCRIPTION,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
) )
const val IDX_ID = 0 const val IDX_ID = 0
@@ -204,7 +197,6 @@ internal object SearchProjection {
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_DESCRIPTION = 12
const val IDX_SELF_ATTENDEE_STATUS = 13
} }
/** /**
@@ -312,3 +304,8 @@ internal object ReminderProjection {
const val IDX_MINUTES = 0 const val IDX_MINUTES = 0
const val IDX_METHOD = 1 const val IDX_METHOD = 1
} }
internal object Fallbacks {
const val UNNAMED_CALENDAR = "(Unbenannter Kalender)"
const val UNTITLED_EVENT = "(Ohne Titel)"
}
@@ -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 } }
@@ -21,7 +21,8 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION)) else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart) }.coerceAtLeast(dtStart)
val title = getString(SearchProjection.IDX_TITLE).orEmpty() val rawTitle = getString(SearchProjection.IDX_TITLE)
val title = if (rawTitle.isNullOrEmpty()) Fallbacks.UNTITLED_EVENT else rawTitle
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) { val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
getInt(SearchProjection.IDX_CALENDAR_COLOR) getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else { } else {
@@ -41,6 +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(),
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
) )
} }
@@ -21,7 +21,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineHours
import de.jeanlucmakiola.calendula.ui.common.TimelineScale import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
import de.jeanlucmakiola.calendula.ui.common.storageValue import de.jeanlucmakiola.calendula.ui.common.storageValue
@@ -213,15 +212,14 @@ class SettingsPrefs @Inject constructor(
} }
/** /**
* Whether the week/day timeline seats each hour in its own cell (v2.11, a * Whether the week/day timeline draws a faint separator line at each hour
* separator line until v2.20). Defaults to OFF — the historical flat column; * (v2.11). Defaults to OFF — the historical clean look; users opt in.
* users opt in. The stored key keeps its original name.
*/ */
val showHourGrid: Flow<Boolean> = store.data.map { prefs -> val showHourLines: Flow<Boolean> = store.data.map { prefs ->
prefs[SHOW_HOUR_LINES_KEY] ?: false prefs[SHOW_HOUR_LINES_KEY] ?: false
} }
suspend fun setShowHourGrid(enabled: Boolean) { suspend fun setShowHourLines(enabled: Boolean) {
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled } store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
} }
@@ -288,36 +286,6 @@ class SettingsPrefs @Inject constructor(
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() } store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
} }
/** How many days the multi-day view shows side by side (#193). */
val multiDayCount: Flow<Int> = store.data.map { prefs ->
(prefs[MULTI_DAY_COUNT_KEY] ?: DEFAULT_MULTI_DAY_COUNT).coerceIn(MULTI_DAY_COUNTS)
}
suspend fun setMultiDayCount(count: Int) {
store.edit { it[MULTI_DAY_COUNT_KEY] = count.coerceIn(MULTI_DAY_COUNTS) }
}
/**
* The hours the day, week and multi-day timelines fit to the screen when they
* open (#322). Off by default; the hours survive switching it off.
*/
val timelineHours: Flow<TimelineHours> = store.data.map { prefs ->
TimelineHours.of(
enabled = prefs[TIMELINE_HOURS_ENABLED_KEY] ?: false,
startHour = prefs[TIMELINE_HOURS_START_KEY] ?: TimelineHours.DEFAULT_START_HOUR,
endHour = prefs[TIMELINE_HOURS_END_KEY] ?: TimelineHours.DEFAULT_END_HOUR,
)
}
suspend fun setTimelineHours(hours: TimelineHours) {
val valid = TimelineHours.of(hours.enabled, hours.startHour, hours.endHour)
store.edit {
it[TIMELINE_HOURS_ENABLED_KEY] = valid.enabled
it[TIMELINE_HOURS_START_KEY] = valid.startHour
it[TIMELINE_HOURS_END_KEY] = valid.endHour
}
}
/** /**
* Where the jump-to-today control lives (issue #60). Default OFF — the * Where the jump-to-today control lives (issue #60). Default OFF — the
* historical layout, where it's an extended FAB that fades in above the "+" * historical layout, where it's an extended FAB that fades in above the "+"
@@ -617,24 +585,6 @@ class SettingsPrefs @Inject constructor(
} }
} }
/** Whether the wizard's calendar step has been answered (or skipped). */
val onboardingCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_CALENDARS_KEY] ?: false
}
suspend fun setOnboardingCalendarsDone(done: Boolean = true) {
store.edit { it[ONBOARDING_CALENDARS_KEY] = done }
}
/** Whether the wizard's hidden-calendars step has been answered (or skipped). */
val onboardingHiddenCalendarsDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_HIDDEN_KEY] ?: false
}
suspend fun setOnboardingHiddenCalendarsDone(done: Boolean = true) {
store.edit { it[ONBOARDING_HIDDEN_KEY] = done }
}
/** Whether the wizard's backup step has been answered (or skipped). */ /** Whether the wizard's backup step has been answered (or skipped). */
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs -> val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
prefs[ONBOARDING_BACKUP_KEY] ?: false prefs[ONBOARDING_BACKUP_KEY] ?: false
@@ -979,20 +929,10 @@ class SettingsPrefs @Inject constructor(
if (view in config.enabled) view.name else "!${view.name}" if (view in config.enabled) view.name else "!${view.name}"
} }
/** Keep stored order; insert the new multi-day view after Week on upgrades. */ /** Keep the given order (de-duplicated), then append any views it omits. */
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> { private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
val ordered = seen.distinct().toMutableList() val ordered = seen.distinct()
IMPLEMENTED_VIEWS.forEach { view -> return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
if (view !in ordered) {
val index = if (view == CalendarView.MultiDay && CalendarView.Week in ordered) {
ordered.indexOf(CalendarView.Week) + 1
} else {
ordered.size
}
ordered.add(index, view)
}
}
return ordered
} }
companion object { companion object {
@@ -1036,10 +976,6 @@ class SettingsPrefs @Inject constructor(
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_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
internal val ONBOARDING_CALENDARS_KEY =
booleanPreferencesKey("onboarding_calendars_done")
internal val ONBOARDING_HIDDEN_KEY =
booleanPreferencesKey("onboarding_hidden_calendars_done")
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done") internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done") internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
internal val ONBOARDING_MONTH_STYLE_KEY = internal val ONBOARDING_MONTH_STYLE_KEY =
@@ -1058,14 +994,8 @@ class SettingsPrefs @Inject constructor(
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540 internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
private const val MINUTES_PER_DAY = 1_440 private const val MINUTES_PER_DAY = 1_440
internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes") internal val SNOOZE_MINUTES_KEY = intPreferencesKey("snooze_minutes")
internal val MULTI_DAY_COUNT_KEY = intPreferencesKey("multi_day_count")
internal val TIMELINE_HOURS_ENABLED_KEY = booleanPreferencesKey("timeline_hours_enabled")
internal val TIMELINE_HOURS_START_KEY = intPreferencesKey("timeline_hours_start")
internal val TIMELINE_HOURS_END_KEY = intPreferencesKey("timeline_hours_end")
/** Default snooze delay for the notification "Snooze" action. */ /** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10 const val DEFAULT_SNOOZE_MINUTES = 10
const val DEFAULT_MULTI_DAY_COUNT = 3
val MULTI_DAY_COUNTS = 3..6
internal val CALENDAR_REMINDER_OVERRIDE_KEY = internal val CALENDAR_REMINDER_OVERRIDE_KEY =
stringPreferencesKey("per_calendar_reminder_override") stringPreferencesKey("per_calendar_reminder_override")
internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY = internal val CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY =
@@ -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 ->
@@ -88,7 +88,6 @@ class ReminderNotifier @Inject constructor(
is24Hour = is24Hour, is24Hour = is24Hour,
today = Instant.now().atZone(zone).toLocalDate(), today = Instant.now().atZone(zone).toLocalDate(),
firstDayOfWeek = firstDayOfWeek, firstDayOfWeek = firstDayOfWeek,
todayLabel = context.getString(R.string.reminder_day_today),
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow), tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday), yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
) )
@@ -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(),
) )
@@ -27,12 +27,12 @@ import java.util.Locale
* alone would be ambiguous) * alone would be ambiguous)
* - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time, * - timed, crossing days: "11 Jun, 23:30 – 12 Jun, 00:30" (medium date + short time,
* already unambiguous) * already unambiguous)
* - all-day, one day: "Tomorrow" / "Thu" / "11 Jun 2026" (same relative rules, * - all-day, one day: "11 Jun 2026"
* plus [todayLabel] — no time range to lean on) * - all-day, multi-day: "11 Jun 2026 – 12 Jun 2026"
* - all-day, multi-day: "Tomorrow – 12 Jun 2026" (relative start, explicit end)
* *
* All-day instances store UTC midnights with an exclusive end, so they are read * All-day instances already carry an explicit date, so they never gain a
* in UTC and the end day is the last *covered* day (issue #308). * relative prefix. They store UTC midnights with an exclusive end, so they are
* read in UTC and the end day is the last *covered* day.
*/ */
fun reminderTimeText( fun reminderTimeText(
beginMillis: Long, beginMillis: Long,
@@ -43,7 +43,6 @@ fun reminderTimeText(
is24Hour: Boolean, is24Hour: Boolean,
today: LocalDate, today: LocalDate,
firstDayOfWeek: DayOfWeek, firstDayOfWeek: DayOfWeek,
todayLabel: String,
tomorrowLabel: String, tomorrowLabel: String,
yesterdayLabel: String, yesterdayLabel: String,
): String { ): String {
@@ -54,16 +53,11 @@ fun reminderTimeText(
val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate() val lastDay = Instant.ofEpochMilli(endMillis).atZone(ZoneOffset.UTC).toLocalDate()
.minusDays(1) .minusDays(1)
.coerceAtLeast(firstDay) .coerceAtLeast(firstDay)
val start = relativeDayPrefix( return if (lastDay == firstDay) {
day = firstDay, dateFormat.format(firstDay)
today = today, } else {
firstDayOfWeek = firstDayOfWeek, dateFormat.format(firstDay) + RANGE + dateFormat.format(lastDay)
locale = locale, }
dateFormat = dateFormat,
tomorrowLabel = tomorrowLabel,
yesterdayLabel = yesterdayLabel,
) ?: todayLabel
return if (lastDay == firstDay) start else start + RANGE + dateFormat.format(lastDay)
} }
val timeFormat = timeOfDayFormatter(is24Hour, locale) val timeFormat = timeOfDayFormatter(is24Hour, locale)
@@ -46,19 +46,3 @@ fun hasSystemHiddenCalendars(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
pendingDisabledIds: Set<Long>, pendingDisabledIds: Set<Long>,
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds } ): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
/**
* The failure a calendar list implies, or null when there is something to show.
* Distinguishes "the device has no calendars" from "every one of them is
* switched off" (#239) — both leave the grid empty, but only the second is
* fixed from Settings → Calendars.
*
* The filter sheet's own hidden set is deliberately not folded in: emptying it
* is a deliberate act taken moments earlier, in a sheet that is still one tap
* away.
*/
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
else -> null
}
@@ -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) }
}
@@ -1,18 +0,0 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.TimeZone
/** Lower values appear first in lists and get the upper visible lanes in grids. */
fun EventInstance.displayPriority(zone: TimeZone): Int = when {
isDeclined -> 3
spansMultipleDays(zone) -> 0
isAllDay -> 1
else -> 2
}
/** Shared order for agenda and split day lists. Grid bars add their lane geometry. */
fun eventDisplayComparator(zone: TimeZone): Comparator<EventInstance> =
compareBy<EventInstance> { it.displayPriority(zone) }
.thenBy { it.start }
.thenBy { it.title }
.thenBy { it.instanceId }
@@ -67,36 +67,8 @@ data class EventInstance(
* Instances query already yields one row per occurrence. * Instances query already yields one row per occurrence.
*/ */
val isRecurring: Boolean = false, val isRecurring: Boolean = false,
/**
* This device user's own answer to the invitation, as far as the grids care
* (#180, #230).
*/
val response: EventResponse = EventResponse.Going,
) )
/**
* How this device user stands towards an event's invitation
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
* surfaces draw differently (#230).
*/
enum class EventResponse {
/** Your own event, or one you accepted — including a tentative "maybe". */
Going,
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
Invited,
/**
* You answered "no". 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).
*/
Declined,
}
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
/** /**
* Whether this event has finished relative to [now] — its end is at or before * Whether this event has finished relative to [now] — its end is at or before
* the current instant. An in-progress event (already started but not yet ended) * the current instant. An in-progress event (already started but not yet ended)
@@ -252,14 +224,6 @@ enum class RecurringWriteScope {
enum class FailureReason { enum class FailureReason {
PermissionRevoked, PermissionRevoked,
NoCalendarsConfigured, NoCalendarsConfigured,
AllCalendarsHidden,
/**
* Calendars exist and are switched on, but none can receive an event: every
* one is read-only, app-managed, or not synced to this device. Distinct from
* [AllCalendarsHidden], which a visibility switch fixes.
*/
NoImportTarget,
ProviderUnavailable, ProviderUnavailable,
EventNotFound, EventNotFound,
Unknown, Unknown,
@@ -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"
@@ -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). */
@@ -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,62 +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") -> {
// A component whose END line never arrives ends at the next val end = indexOfEnd(lines, i + 1, "VEVENT")
// one's BEGIN, not at that one's END: reading to the far END parseVevent(lines.subList(i + 1, end), calendarName, warnings)
// would fold two events into one body, where the later ?.let(events::add)
// properties overwrite the earlier and one event is lost. i = end + 1
val end = indexOfEnd(lines, i + 1, component)
val body = indexOfNextComponent(lines, i + 1, end)
parseComponent(
body = lines.subList(i + 1, body),
fileCalendarName = calendarName,
warnings = warnings,
isTask = component == "VTODO",
)?.let(events::add)
i = if (body < end) body else 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
@@ -209,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
@@ -230,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)
@@ -245,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++
} }
@@ -266,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) }
@@ -393,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? {
@@ -421,52 +218,7 @@ 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. */
/**
* Where an unterminated component's body has to stop: the next top-level
* `VEVENT` / `VTODO` in `[from, end)`, or [end] when there is none. A
* nested `VALARM` is part of the body and is not a boundary.
*/
fun indexOfNextComponent(lines: List<String>, from: Int, end: Int): Int {
var i = from
while (i < end) {
val line = parseContentLine(lines[i])
if (line != null && (line.isBegin("VEVENT") || line.isBegin("VTODO"))) {
return i
}
i++
}
return 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
while (i < lines.size) { while (i < lines.size) {
@@ -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)
}
@@ -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")
@@ -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() },
) )
@@ -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,19 +70,17 @@ 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() PlannedReminder(
.map { minutes -> instance = instance,
PlannedReminder( minutes = minutes,
instance = instance, alarmMillis = if (instance.isAllDay) {
minutes = minutes, allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
alarmMillis = if (instance.isAllDay) { } else {
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes) instance.beginMillis - minutes * MILLIS_PER_MINUTE
} else { },
instance.beginMillis - minutes * MILLIS_PER_MINUTE )
}, }
)
}
} }
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */ /** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
@@ -1,40 +1,24 @@
package de.jeanlucmakiola.calendula.ui package de.jeanlucmakiola.calendula.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.SeekableTransitionState
import androidx.compose.animation.core.rememberTransition
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
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.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
@@ -42,39 +26,17 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
import de.jeanlucmakiola.calendula.ui.common.StayingPut
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
import de.jeanlucmakiola.calendula.ui.common.ChromeSlot
import de.jeanlucmakiola.calendula.ui.common.LocalChromeSlot
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBackExit
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineScroll
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewFocus
import de.jeanlucmakiola.calendula.ui.common.ViewFocusSaver
import de.jeanlucmakiola.calendula.ui.common.LocalViewMorph
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.TimelineScrollMemory
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.ViewMorphScope
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberViewMorphBoundsTransform
import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.drillToWeek
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.selectView import de.jeanlucmakiola.calendula.ui.common.selectView
import de.jeanlucmakiola.calendula.ui.common.viewBaseStack import de.jeanlucmakiola.calendula.ui.common.viewBaseStack
import de.jeanlucmakiola.calendula.ui.day.DayScreen
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen
import de.jeanlucmakiola.calendula.ui.edit.ImportSource import de.jeanlucmakiola.calendula.ui.edit.ImportSource
@@ -82,21 +44,16 @@ import de.jeanlucmakiola.calendula.ui.imports.ImportScreen
import de.jeanlucmakiola.calendula.ui.month.MonthScreen import de.jeanlucmakiola.calendula.ui.month.MonthScreen
import de.jeanlucmakiola.calendula.ui.search.SearchScreen import de.jeanlucmakiola.calendula.ui.search.SearchScreen
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.calendula.ui.week.TimelineScreen import de.jeanlucmakiola.calendula.ui.week.WeekScreen
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Clock import kotlin.time.Clock
/** /**
* Holds the top-level view back stack (spec M1) and swaps between the calendar * Holds the top-level view back stack (spec M1) and swaps between the calendar
* screens. Each screen owns its own ViewModel; they all open on the one focused * screens. Each screen owns its own ViewModel and date anchor; the view-switcher
* date held here ([ViewFocus]), and the view-switcher pill in their top bars * pill in their top bars writes back here via [onSelectView].
* writes back here via [onSelectView].
* *
* The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view. * The stack's bottom is the user's [CalendarHostViewModel.defaultView] home view.
* A lateral switch (pill / drawer) builds a visit history so back retraces it * A lateral switch (pill / drawer) builds a visit history so back retraces it
@@ -113,7 +70,6 @@ import kotlin.time.Clock
* as [WidgetNavRequest.OpenEvent], which also roots the back stack in the * as [WidgetNavRequest.OpenEvent], which also roots the back stack in the
* widget's view.) * widget's view.)
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CalendarHost( fun CalendarHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -145,25 +101,14 @@ fun CalendarHost(
var viewStack by rememberSaveable(stateSaver = viewStackSaver) { var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView)) mutableStateOf(listOf(defaultView))
} }
var navigationVersion by remember { mutableIntStateOf(0) }
val view = viewStack.last() val view = viewStack.last()
// The date every view opens on and carries along (#184); tapping a day val onSelectView: (CalendarView) -> Unit = { viewStack = viewStack.selectView(it) }
// anywhere focuses it and opens the day view there.
val focus = rememberSaveable(saver = ViewFocusSaver) { ViewFocus(localToday()) }
val switchScope = rememberCoroutineScope() // Tapping a day in the month grid opens the day view anchored to that date.
val switchTo: ((List<CalendarView>) -> List<CalendarView>) -> Unit = { move -> var pendingDayIso by rememberSaveable { mutableStateOf<String?>(null) }
navigationVersion++
viewStack = move(viewStack)
}
val onSelectView: (CalendarView) -> Unit = { target -> switchTo { it.selectView(target) } }
val onOpenDay: (LocalDate) -> Unit = { date -> val onOpenDay: (LocalDate) -> Unit = { date ->
focus.navigateTo(date) pendingDayIso = date.toString()
switchTo { it.drillToDay() } viewStack = viewStack.drillToDay()
}
val onOpenWeek: (LocalDate) -> Unit = { date ->
focus.navigateTo(date)
switchTo { it.drillToWeek() }
} }
// The event-detail screen (S4) is a full-screen destination hoisted here so // The event-detail screen (S4) is a full-screen destination hoisted here so
@@ -251,21 +196,10 @@ fun CalendarHost(
// is "restore a backup", not "add this one event". An externally opened .ics // is "restore a backup", not "add this one event". An externally opened .ics
// keeps routing a single event straight into the prefilled create form. // keeps routing a single event straight into the prefilled create form.
var importForceMany by remember { mutableStateOf(false) } var importForceMany by remember { mutableStateOf(false) }
// Where a restore came from, so closing the import puts it back: the import
// overlays are declared under Backup & restore and the manager, so starting
// one has to close them — without this, finishing a restore drops you on the
// calendar and the next file means walking in through Settings again.
var backupAfterImport by rememberSaveable { mutableStateOf(false) }
var calendarsAfterImport by rememberSaveable { mutableStateOf(false) }
// One import run. The import VM lives in the Activity's store, so this is
// what tells it a *re-import of the same file* is new work and not the run
// it already finished; a rotation keeps the number and keeps the result.
var importSession by rememberSaveable { mutableStateOf(0) }
LaunchedEffect(requestedImportUri) { LaunchedEffect(requestedImportUri) {
if (requestedImportUri != null) { if (requestedImportUri != null) {
importUri = requestedImportUri importUri = requestedImportUri
importForceMany = false importForceMany = false
importSession++
onImportConsumed() onImportConsumed()
} }
} }
@@ -313,14 +247,13 @@ fun CalendarHost(
// [requestedDetailKey]. Date/event opens root the stack in the widget's own // [requestedDetailKey]. Date/event opens root the stack in the widget's own
// view so backing out returns there (then home), not to the default. // view so backing out returns there (then home), not to the default.
LaunchedEffect(widgetNavRequest) { LaunchedEffect(widgetNavRequest) {
if (widgetNavRequest != null) navigationVersion++
when (val req = widgetNavRequest) { when (val req = widgetNavRequest) {
is WidgetNavRequest.OpenDate -> { is WidgetNavRequest.OpenDate -> {
// Drill the day view in over the widget's view: drop any overlay // Drill the day view in over the widget's view: drop any overlay
// that would cover it, so the open doesn't land under Settings/form. // that would cover it, so the open doesn't land under Settings/form.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createDateIso = null
focus.navigateTo(LocalDate.parse(req.dateIso)) pendingDayIso = req.dateIso
// No widget source (an external date tap) roots over the default // No widget source (an external date tap) roots over the default
// home view, so backing out of the day returns home then exits. // home view, so backing out of the day returns home then exits.
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay() viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
@@ -344,7 +277,7 @@ fun CalendarHost(
// default home — so backing out returns to the default, then exits. // default home — so backing out returns to the default, then exits.
dismissCoveringOverlays() dismissCoveringOverlays()
createDateIso = null createDateIso = null
focus.navigateTo(localToday()) pendingDayIso = null
viewStack = viewBaseStack(defaultView, req.view ?: defaultView) viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed() onWidgetNavConsumed()
} }
@@ -386,18 +319,14 @@ fun CalendarHost(
EventMoveScope( EventMoveScope(
movableCalendarIds = movableCalendarIds, movableCalendarIds = movableCalendarIds,
dragEnabled = dragToReschedule, dragEnabled = dragToReschedule,
move = { reschedule.move(it) }, move = reschedule::move,
inFlight = reschedule.inFlight, inFlight = reschedule.inFlight,
undoStarted = reschedule.undoStarted, undoStarted = reschedule.undoStarted,
abandoned = reschedule.abandoned,
edit = onEditEvent, edit = onEditEvent,
) )
} }
val slideSpec = rememberCalendarSlideSpec() val slideSpec = rememberCalendarSlideSpec()
// Overlays leave from the predictive-back preview (#184): shrinking on from
// where the gesture let go instead of snapping back and sliding off.
val overlayExit = predictiveBackExit()
// Base-level back: pop the view stack while no overlay covers it (each overlay // Base-level back: pop the view stack while no overlay covers it (each overlay
// owns its own BackHandler and takes precedence). Disabled at the home view, // owns its own BackHandler and takes precedence). Disabled at the home view,
@@ -405,158 +334,71 @@ fun CalendarHost(
val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null || val anyOverlayVisible = showSearch || detailKey != null || createDateIso != null ||
editKey != null || showSettings || showCalendars || showBackup || editKey != null || showSettings || showCalendars || showBackup ||
importUri != null || importForm != null importUri != null || importForm != null
// The view switch runs on a seekable transition, so a back gesture can scrub BackHandler(enabled = !anyOverlayVisible && viewStack.size > 1) {
// it (#185): the previous view morphs in under the finger, releasing finishes viewStack = viewStack.dropLast(1)
// the pop and cancelling springs back. Reduced motion keeps a plain back.
val viewTransition = remember { SeekableTransitionState(view) }
LaunchedEffect(view) { viewTransition.animateTo(view) }
val reduceMotion = rememberReduceMotion()
val viewBackEnabled = !anyOverlayVisible && viewStack.size > 1
if (reduceMotion) {
BackHandler(enabled = viewBackEnabled) {
navigationVersion++
viewStack = viewStack.dropLast(1)
}
} else {
PredictiveBackHandler(enabled = viewBackEnabled) { events ->
val previous = viewStack[viewStack.size - 2]
try {
events.collect { viewTransition.seekTo(it.progress, targetState = previous) }
navigationVersion++
viewStack = viewStack.dropLast(1)
} catch (e: CancellationException) {
withContext(NonCancellable) { viewTransition.animateTo(viewTransition.currentState) }
throw e
}
}
} }
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
// Switching between the month, timeline and agenda screens is lateral // Switching between the peer views (month/week/day/agenda) is lateral
// navigation, so it fades through rather than sliding — paging *within* a // navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. What both views show morphs across // view keeps the directional slide. AnimatedContent keyed on the view type.
// (#184): see [ViewMorphKey]. Reduced motion keeps the plain fade.
val viewSwitch = fadeThrough() val viewSwitch = fadeThrough()
val morphBounds = rememberViewMorphBoundsTransform() CompositionLocalProvider(LocalEventMove provides moveScope) {
val morphFade = rememberCalendarFadeSpec() AnimatedContent(
val morphClip = remember { BelowTopBarClip() } targetState = view,
val stayingPut = remember { StayingPut() }
val chromeHost = remember { ChromeHost() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val density = LocalDensity.current
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
val timelineScroll = remember { TimelineScrollMemory() }
// A timeline reached from another screen opens on the visible hours
// (#322); only a switch between timeline views carries the scroll over.
val timelineZoom = LocalTimelineZoom.current
LaunchedEffect(view.screen) {
if (view.screen != ViewScreen.Timeline && timelineZoom.hours.enabled) {
timelineScroll.midnightY = null
}
}
CompositionLocalProvider(
LocalEventMove provides moveScope,
LocalTimelineScroll provides timelineScroll,
LocalViewFocus provides focus,
// The failure state's way out when every calendar is switched off
// (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides remember { { showCalendars = true } },
) {
// One drawer, top bar and FAB for every screen (#184): a switch changes
// what they say, from what the screen on show publishes, rather than
// swapping them for another screen's.
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button where edge-swipe would fight paging;
// the agenda has no paging to fight.
gesturesEnabled = drawerState.isOpen || view.screen == ViewScreen.Agenda,
drawerContent = {
CalendarDrawer(
currentView = view,
currentDate = chromeHost.chrome?.drawerDate ?: focus.date,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { target ->
// Once the drawer is out of the way, so the switch is seen whole.
val requestVersion = ++navigationVersion
switchScope.launch {
drawerState.close()
if (navigationVersion == requestVersion) onSelectView(target)
}
},
onJumpToDate = { target ->
chromeHost.actions?.onJumpToDate?.invoke(target)
switchScope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
switchScope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
topBar = {
CalendarTopBar(
host = chromeHost,
selectedView = view,
quickSwitchViews = quickSwitchViews,
onCycleView = { onSelectView(viewStack.last().next(quickSwitchViews)) },
onOpenDrawer = { switchScope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
)
},
floatingActionButton = { CalendarFab(chromeHost, todayInToolbar) },
) { innerPadding ->
SharedTransitionLayout(
Modifier
.padding(innerPadding)
.fillMaxSize()
.onGloballyPositioned { morphClip.top = topBarBottom - it.positionInWindow().y },
) {
// Keyed on the screen, not the view: the day, multi-day and week views
// are one timeline that resizes between them itself (#184), so switching
// among them must not replay this transition.
rememberTransition(viewTransition, label = "view-switch").AnimatedContent(
transitionSpec = { viewSwitch }, transitionSpec = { viewSwitch },
contentKey = { it.screen }, label = "view-switch",
) { currentView -> ) { currentView ->
val screen = currentView.screen when (currentView) {
val sharedScope = this@SharedTransitionLayout CalendarView.Week -> WeekScreen(
val visibilityScope = this@AnimatedContent selectedView = currentView,
val viewMorph = remember(reduceMotion, sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut) { onSelectView = onSelectView,
if (reduceMotion) null else ViewMorphScope(sharedScope, visibilityScope, morphBounds, morphFade, morphClip, stayingPut)
}
CompositionLocalProvider(
LocalViewMorph provides viewMorph,
LocalChromeSlot provides ChromeSlot(chromeHost, current = screen == view.screen),
) {
when (screen) {
ViewScreen.Timeline -> TimelineScreen(
view = currentView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
)
ViewScreen.Month -> MonthScreen(
onOpenDay = onOpenDay,
onOpenWeek = onOpenWeek,
onEventClick = onEventClick,
onCreateEvent = onCreateEvent,
)
ViewScreen.Agenda -> AgendaScreen(
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews, quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Day -> DayScreen(
selectedView = currentView,
onSelectView = onSelectView,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Month -> MonthScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
onSelectView = onSelectView,
onOpenDay = onOpenDay,
onEventClick = onEventClick,
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
todayInToolbar = todayInToolbar,
) )
} }
}
}
}
}
} }
} }
@@ -569,12 +411,11 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showSearch, visible = showSearch,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit, exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) { ) {
SearchScreen( SearchScreen(
onBack = { showSearch = false }, onBack = { showSearch = false },
onEventClick = onEventClick, onEventClick = onEventClick,
visible = showSearch,
) )
} }
@@ -583,7 +424,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = detailKey != null, visible = detailKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit, exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) { ) {
activeKey?.let { key -> activeKey?.let { key ->
EventDetailScreen( EventDetailScreen(
@@ -603,7 +444,6 @@ fun CalendarHost(
importForm = form importForm = form
detailKey = null detailKey = null
}, },
visible = detailKey != null,
) )
} }
} }
@@ -612,7 +452,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = createDateIso != null, visible = createDateIso != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit, exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) { ) {
(createDateIso ?: heldCreateIso)?.let { iso -> (createDateIso ?: heldCreateIso)?.let { iso ->
EventEditScreen( EventEditScreen(
@@ -621,7 +461,6 @@ fun CalendarHost(
onClose = { createDateIso = null }, onClose = { createDateIso = null },
onSaved = { createDateIso = null }, onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
visible = createDateIso != null,
) )
} }
} }
@@ -630,7 +469,7 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = editKey != null, visible = editKey != null,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit, exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) { ) {
(editKey ?: heldEditKey)?.let { key -> (editKey ?: heldEditKey)?.let { key ->
EventEditScreen( EventEditScreen(
@@ -642,7 +481,6 @@ fun CalendarHost(
detailKey = null detailKey = null
}, },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
visible = editKey != null,
) )
} }
} }
@@ -651,13 +489,12 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showSettings, visible = showSettings,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit, exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) { ) {
SettingsScreen( SettingsScreen(
onBack = { showSettings = false }, onBack = { showSettings = false },
onManageCalendars = { showCalendars = true }, onManageCalendars = { showCalendars = true },
onOpenBackup = { showBackup = true }, onOpenBackup = { showBackup = true },
visible = showSettings,
) )
} }
@@ -666,28 +503,11 @@ fun CalendarHost(
importUri?.let { uri -> importUri?.let { uri ->
ImportScreen( ImportScreen(
uri = uri, uri = uri,
session = importSession,
forceMany = importForceMany, forceMany = importForceMany,
onClose = { onClose = { importUri = null },
importUri = null onManageCalendars = { showCalendars = true },
// Back to the surface the restore started from, ready for
// the next file.
showCalendars = calendarsAfterImport
showBackup = backupAfterImport
calendarsAfterImport = false
backupAfterImport = false
},
onManageCalendars = {
// The manager is where this leads, so it must not be
// reopened underneath on close.
calendarsAfterImport = false
backupAfterImport = false
showCalendars = true
},
onOpenSingle = { form -> onOpenSingle = { form ->
importUri = null importUri = null
calendarsAfterImport = false
backupAfterImport = false
importFormSource = ImportSource.File importFormSource = ImportSource.File
importForm = form importForm = form
}, },
@@ -709,12 +529,11 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showCalendars, visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit, exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) { ) {
CalendarsScreen( CalendarsScreen(
onBack = { showCalendars = false }, onBack = { showCalendars = false },
onOpenBackup = { showBackup = true }, onOpenBackup = { showBackup = true },
visible = showCalendars,
) )
} }
@@ -722,36 +541,20 @@ fun CalendarHost(
AnimatedVisibility( AnimatedVisibility(
visible = showBackup, visible = showBackup,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = overlayExit, exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) { ) {
// Settings can open this screen without the manager underneath, so BackupScreen(
// its failure states' "Manage calendars" way out has to open the onBack = { showBackup = false },
// manager rather than just pop — popping alone lands on Settings // Restore runs the normal .ics import, and both this screen and
// (#304). Coming from the manager the flag is already set, so this // the manager that can have opened it are declared above the
// is the same pop-back there. // import overlays — so both have to step aside.
CompositionLocalProvider( onImport = {
LocalManageCalendars provides { importUri = it
showCalendars = true importForceMany = true
showBackup = false showBackup = false
showCalendars = false
}, },
) { )
BackupScreen(
onBack = { showBackup = false },
// Restore runs the normal .ics import, and both this screen
// and the manager that can have opened it are declared above
// the import overlays — so both have to step aside.
onImport = {
importUri = it
importForceMany = true
importSession++
backupAfterImport = true
calendarsAfterImport = showCalendars
showBackup = false
showCalendars = false
},
visible = showBackup,
)
}
} }
} }
} }
@@ -761,6 +564,3 @@ private val viewStackSaver = listSaver<List<CalendarView>, Int>(
save = { stack -> stack.map(CalendarView::ordinal) }, save = { stack -> stack.map(CalendarView::ordinal) },
restore = { ordinals -> ordinals.map { CalendarView.entries[it] } }, restore = { ordinals -> ordinals.map { CalendarView.entries[it] } },
) )
private fun localToday(): LocalDate =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
@@ -24,14 +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.domain.isDeclined
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
@@ -132,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 = {
@@ -1,18 +1,5 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.ui.common.PublishChrome
import de.jeanlucmakiola.calendula.ui.common.ScreenActions
import de.jeanlucmakiola.calendula.ui.common.ScreenBody
import de.jeanlucmakiola.calendula.ui.common.ScreenChrome
import de.jeanlucmakiola.calendula.ui.common.LocalViewFocus
import de.jeanlucmakiola.calendula.ui.common.EnterOnFocus
import kotlinx.coroutines.flow.drop
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.rememberUpdatedState
import de.jeanlucmakiola.calendula.ui.common.ViewMorphKey
import de.jeanlucmakiola.calendula.ui.common.morphEvent
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -26,25 +13,34 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Coffee import androidx.compose.material.icons.filled.Coffee
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
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.unit.dp import androidx.compose.ui.unit.dp
@@ -55,13 +51,16 @@ 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.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
@@ -77,22 +76,27 @@ import kotlin.time.Instant
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AgendaScreen( fun AgendaScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS, quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(), viewModel: AgendaViewModel = hiltViewModel(),
) { ) {
// Opens on the focused date and hands back the day scrolled to the top.
val focus = LocalViewFocus.current
EnterOnFocus(viewModel) { if (it != viewModel.anchor.value) viewModel.goToDate(it) }
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val anchor by viewModel.anchor.collectAsStateWithLifecycle() val anchor by viewModel.anchor.collectAsStateWithLifecycle()
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle() val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
val showToday by viewModel.showToday.collectAsStateWithLifecycle() val 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 scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showRangePicker by remember { mutableStateOf(false) } var showRangePicker by remember { mutableStateOf(false) }
@@ -102,102 +106,92 @@ fun AgendaScreen(
} }
val successState = state as? AgendaUiState.Success val successState = state as? AgendaUiState.Success
// The agenda window always starts at the anchor, so moving through it is a ModalNavigationDrawer(
// list scroll, not an anchor change — "today" has to bring the list back to drawerState = drawerState,
// the top as well, and stay offered while it is scrolled away (#305). drawerContent = {
val listState = rememberLazyListState() CalendarDrawer(
val scrolledAway by remember { currentView = selectedView,
derivedStateOf { currentDate = anchor,
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 drawerState = drawerState,
} viewOrder = drawerViewOrder,
} onSelectView = { view ->
// A new window is rendered from its first day, so the list belongs at the onSelectView(view)
// top. Keyed on the anchor the days arrived with, not the one just scope.launch { drawerState.close() }
// requested: scrolling before the new rows compose leaves the list keyed to },
// a row that reappears further down, and it follows it there. onJumpToDate = { target ->
LaunchedEffect(successState?.anchor) { listState.scrollToItem(0) } viewModel.goToDate(target)
val jumpToToday: () -> Unit = { scope.launch { drawerState.close() }
// Off today, moving the anchor is what resets the list (above); on today },
// the window doesn't change, so the scroll back is the whole action. onSettings = {
if (isOnToday) scope.launch { listState.animateScrollToItem(0) } onOpenSettings()
viewModel.goToToday() scope.launch { drawerState.close() }
(state as? AgendaUiState.Success)?.today?.let { focus?.date = it } },
}
PublishChrome(
ScreenChrome(
title = stringResource(R.string.view_agenda),
titleDate = null,
drawerDate = anchor,
awayFromToday = !isOnToday || (scrolledAway && successState != null),
todayText = stringResource(R.string.agenda_today_action),
),
ScreenActions(
onToday = jumpToToday,
onJumpToDate = { target ->
focus?.date = target
viewModel.goToDate(target)
},
onCreate = { onCreateEvent(anchor, null) },
),
)
ScreenBody(modifier = modifier) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
// 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(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
}
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
listState = listState,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
onTopDay = if (successState?.anchor == anchor) {
{ focus?.date = it }
} else null,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) )
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
showTodayButton = todayInToolbar,
onToday = viewModel::goToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.agenda_today_action),
onToday = viewModel::goToToday,
onCreate = { onCreateEvent(anchor, null) },
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
// One bar at the top: the "showing …" header on the left and the
// session range switcher on the right (one settings toggle).
successState?.takeIf { it.showRangeBar }?.let { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
// 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
.fillMaxWidth()
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
AgendaRangeBanner(
range = s.range,
start = s.anchor,
end = s.rangeEnd,
modifier = Modifier.weight(1f),
)
AgendaRangePill(
range = s.range,
isOverride = s.rangeIsOverride,
onClick = { showRangePicker = true },
)
}
}
AgendaContent(
state = state,
pastDisplay = pastDisplay,
showToday = showToday,
onRetry = viewModel::goToToday,
onEventClick = onEventClick,
onOpenDay = onOpenDay,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
)
}
} }
} }
@@ -251,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
@@ -293,8 +284,6 @@ internal fun AgendaContent(
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
onTopDay: ((LocalDate) -> Unit)? = null,
) { ) {
when (state) { when (state) {
AgendaUiState.Loading -> Box(modifier) AgendaUiState.Loading -> Box(modifier)
@@ -323,21 +312,16 @@ internal fun AgendaContent(
enabled = showToday && state.anchor == state.today, enabled = showToday && state.anchor == state.today,
) )
if (days.isEmpty()) { if (days.isEmpty()) {
// Nothing to scroll: drop the position a previous list left, so
// the today FAB doesn't linger over an empty screen.
LaunchedEffect(Unit) { listState.scrollToItem(0) }
AgendaEmpty(modifier) AgendaEmpty(modifier)
} else { } else {
AgendaList( AgendaList(
days = days, days = days,
today = state.today, today = state.today,
zone = state.zone, zone = state.zone,
listState = listState,
dimPast = pastDisplay == PastEventDisplay.DIM, dimPast = pastDisplay == PastEventDisplay.DIM,
now = now, now = now,
onEventClick = onEventClick, onEventClick = onEventClick,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onTopDay = onTopDay,
modifier = modifier, modifier = modifier,
) )
} }
@@ -351,28 +335,14 @@ private fun AgendaList(
days: List<AgendaDay>, days: List<AgendaDay>,
today: LocalDate, today: LocalDate,
zone: TimeZone, zone: TimeZone,
listState: LazyListState,
dimPast: Boolean, dimPast: Boolean,
now: Instant, now: Instant,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onTopDay: ((LocalDate) -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (onTopDay != null) {
val itemDates by rememberUpdatedState(agendaItemDates(days))
val currentOnTopDay by rememberUpdatedState(onTopDay)
// The day the list opened on is where the view already is; only a
// scroll moves it on.
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.drop(1)
.collect { index -> itemDates.getOrNull(index)?.let(currentOnTopDay) }
}
}
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState,
// 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(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) { ) {
@@ -402,7 +372,7 @@ private fun AgendaList(
zone = zone, zone = zone,
position = positionOf(index, day.events.size), position = positionOf(index, day.events.size),
dimmed = dimPast && event.hasEnded(now), dimmed = dimPast && event.hasEnded(now),
modifier = animateItemMotion().morphEvent(ViewMorphKey.event(event, day.date)), modifier = animateItemMotion(),
onClick = { onEventClick(event) }, onClick = { onEventClick(event) },
) )
} }
@@ -412,13 +382,6 @@ private fun AgendaList(
} }
} }
/** The day of each item [AgendaList] lays out for [days], in its order. */
internal fun agendaItemDates(days: List<AgendaDay>): List<LocalDate> =
days.flatMap { day ->
// Header, the placeholder or the events, then the gap.
List(2 + day.events.size.coerceAtLeast(1)) { day.date }
}
@Composable @Composable
private fun AgendaEmpty(modifier: Modifier = Modifier) { private fun AgendaEmpty(modifier: Modifier = Modifier) {
Column( Column(
@@ -440,3 +403,51 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
) )
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AgendaTopBar(
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
) {
TopAppBar(
title = {
Text(
text = stringResource(R.string.view_agenda),
style = MaterialTheme.typography.titleLarge,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.ui.agenda package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.eventDisplayComparator
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay import de.jeanlucmakiola.calendula.domain.spanLastDay
@@ -87,7 +86,11 @@ fun groupAgendaDays(
return byDay.map { (date, dayEvents) -> return byDay.map { (date, dayEvents) ->
AgendaDay( AgendaDay(
date = date, date = date,
events = dayEvents.sortedWith(eventDisplayComparator(zone)), events = dayEvents.sortedWith(
compareByDescending<EventInstance> { it.isAllDay }
.thenBy { it.start }
.thenBy { it.title },
),
) )
} }
} }
@@ -11,7 +11,6 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
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
@@ -157,7 +156,9 @@ class AgendaViewModel @Inject constructor(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
): AgendaUiState { ): AgendaUiState {
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) } if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val anchor = params.anchor val anchor = params.anchor
val rangeEnd = anchor.plus( val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1, params.range.dayCount(anchor, params.weekStart) - 1,
@@ -4,11 +4,9 @@ import android.net.Uri
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
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.layout.padding import androidx.compose.foundation.layout.padding
@@ -20,7 +18,6 @@ import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -50,10 +47,9 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.DialogAmountField import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.DialogUnitDropdown import de.jeanlucmakiola.floret.components.DialogUnitDropdown
@@ -82,16 +78,20 @@ private val RESTORE_MIME_TYPES = arrayOf(
fun BackupScreen( fun BackupScreen(
onBack: () -> Unit, onBack: () -> Unit,
onImport: (Uri) -> Unit, onImport: (Uri) -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.backupState.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle() val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle() val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Export covers local calendars only; managed special-dates mirrors are
// rebuilt from contacts. Restore can target anything the import picker offers.
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
// Exports everything eligible (null); the per-calendar selector owns its // Exports everything eligible (null); the per-calendar selector owns its
// own launcher. // own launcher.
val createBackup = rememberLauncherForActivityResult( val createBackup = rememberLauncherForActivityResult(
@@ -132,43 +132,78 @@ fun BackupScreen(
CollapsingScaffold( CollapsingScaffold(
title = stringResource(R.string.settings_section_backup), title = stringResource(R.string.settings_section_backup),
onBack = onBack, onBack = onBack,
// Loading and failure fill the screen and centre themselves, which they
// can only do in an unscrolled column — the scrolling one measures them
// against an unbounded height and leaves them hanging under the header.
scrollable = state is BackupUiState.Ready,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
when (val s = state) { HintText(stringResource(R.string.calendars_backup_hint))
BackupUiState.Loading -> BackupLoading()
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack) if (exportable.isNotEmpty()) {
is BackupUiState.Ready -> BackupContent( GroupedRow(
exportable = s.exportable, title = stringResource(R.string.calendars_backup_action),
canImport = s.canImport, position = Position.Top,
autoBackup = autoBackup, leading = { LeadingAvatar(Icons.Default.FileDownload) },
viewModel = viewModel, onClick = {
onRestore = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } }, // A single exportable calendar skips the selector.
onPickFolder = { runCatching { pickFolder.launch(null) } }, if (exportable.size == 1) {
onExportAll = { runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
runCatching { createBackup.launch(defaultBackupName()) } } else {
showExportPicker = true
}
}, },
onExportPick = { showExportPicker = true }, )
onEditInterval = { showInterval = true }, GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = stringResource(R.string.calendars_restore_hint),
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
},
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = { runCatching { pickFolder.launch(null) } },
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = { showInterval = true },
)
HintText(backupStatusText(autoBackup.status))
}
} else if (canImport) {
// Nothing to back up, but restore is still possible — don't hide
// it behind export eligibility.
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(stringResource(R.string.calendars_restore_hint))
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
) )
} }
} }
// Gated on Ready rather than resetting the flag when the state leaves it: if (showExportPicker) {
// flipping it here would be a write during composition. ExportCalendarPicker(
(state as? BackupUiState.Ready)?.let { ready -> calendars = exportable,
if (showExportPicker) { onExport = viewModel::exportBackup,
ExportCalendarPicker( onDismiss = { showExportPicker = false },
calendars = ready.exportable, )
onExport = viewModel::exportBackup,
onDismiss = { showExportPicker = false },
)
}
} }
if (showInterval) { if (showInterval) {
BackupIntervalDialog( BackupIntervalDialog(
@@ -179,103 +214,6 @@ fun BackupScreen(
} }
} }
/** The screen's loading state, centred in the scaffold's content column. */
@Composable
private fun BackupLoading() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
/** Default file name for a one-shot export. */
private fun defaultBackupName(): String = "calendula-backup-${LocalDate.now()}.ics"
/**
* The working screen: export (when there are local calendars to export), restore,
* and the automatic-backup block. Restore is always on screen — when nothing can
* receive the events it says so and routes to the calendar manager rather than
* disappearing, which is what #304 reported as an invisible button.
*/
@Composable
private fun BackupContent(
exportable: List<CalendarSource>,
canImport: Boolean,
autoBackup: AutoBackupUiState,
viewModel: CalendarsViewModel,
onRestore: () -> Unit,
onPickFolder: () -> Unit,
onExportAll: () -> Unit,
onExportPick: () -> Unit,
onEditInterval: () -> Unit,
) {
val manageCalendars = LocalManageCalendars.current
// Restore never depends on export eligibility, and never disappears: when no
// calendar can receive events it explains that and offers the way to fix it.
val restoreSummary = if (canImport) {
stringResource(R.string.calendars_restore_hint)
} else {
stringResource(R.string.calendars_restore_unavailable)
}
val onRestoreClick = if (canImport) onRestore else (manageCalendars ?: onRestore)
if (exportable.isEmpty()) {
SectionHeader(stringResource(R.string.calendars_restore_header))
HintText(restoreSummary)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
return
}
HintText(stringResource(R.string.calendars_backup_hint))
GroupedRow(
title = stringResource(R.string.calendars_backup_action),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
// A single exportable calendar skips the selector.
onClick = if (exportable.size == 1) onExportAll else onExportPick,
)
GroupedRow(
title = stringResource(R.string.calendars_restore_action),
summary = restoreSummary,
position = Position.Middle,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup),
summary = stringResource(R.string.calendars_auto_backup_hint),
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
leading = { LeadingAvatar(Icons.Default.Schedule) },
trailing = {
Switch(checked = autoBackup.enabled, onCheckedChange = viewModel::setAutoBackupEnabled)
},
onClick = { viewModel.setAutoBackupEnabled(!autoBackup.enabled) },
)
if (autoBackup.enabled) {
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_folder),
summary = rememberFolderName(autoBackup.folderUri)
?: stringResource(R.string.calendars_auto_backup_folder_unset),
position = Position.Middle,
onClick = onPickFolder,
)
GroupedRow(
title = stringResource(R.string.calendars_auto_backup_interval),
summary = backupIntervalLabel(autoBackup.intervalMinutes),
position = Position.Bottom,
onClick = onEditInterval,
)
HintText(backupStatusText(autoBackup.status))
}
}
/** /**
* Choose which local calendars to include in a one-time `.ics` export. Defaults * Choose which local calendars to include in a one-time `.ics` export. Defaults
* to all selected; the Export action opens the SAF save dialog and hands back * to all selected; the Export action opens the SAF save dialog and hands back
@@ -314,7 +252,7 @@ private fun ExportCalendarPicker(
calendars.forEachIndexed { index, calendar -> calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id in selected val isSelected = calendar.id in selected
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName,
summary = calendar.description, summary = calendar.description,
position = positionOf(index, calendars.size), position = positionOf(index, calendars.size),
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
@@ -1,53 +0,0 @@
package de.jeanlucmakiola.calendula.ui.calendars
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.domain.isEventTarget
/**
* State of the Backup & restore screen (#69). Three states, because the calendar
* list it is derived from has all three: it arrives empty, it can throw, and
* "loaded but nothing to offer" is a real outcome the screen used to render as a
* blank page (#304).
*/
sealed interface BackupUiState {
data object Loading : BackupUiState
data class Failure(val reason: FailureReason) : BackupUiState
/**
* At least one half of the screen works. [exportable] may be empty (restore
* only) and [canImport] may be false (export only), but never both — that is
* a [Failure].
*/
data class Ready(
val exportable: List<CalendarSource>,
val canImport: Boolean,
) : BackupUiState
}
/**
* What the screen can offer for this calendar list.
*
* Export covers the app's own local calendars; managed special-dates mirrors are
* rebuilt from contacts, so they are excluded. Restore can target anything the
* import picker offers ([isEventTarget]).
*
* A failure is raised only when *neither* is possible — deliberately not
* [calendarListFailure] on its own, which would call an all-hidden device a
* failure while its local calendars are still perfectly exportable. When both
* halves are dead the list itself usually says why (no calendars, everything
* switched off); [FailureReason.NoImportTarget] covers the remaining case, where
* calendars exist and are visible but every one of them is read-only, managed or
* not synced to the device.
*/
fun backupUiState(calendars: List<CalendarSource>): BackupUiState {
val exportable = calendars.filter { it.isLocal && it.canModifyContents && !it.isManaged }
val canImport = calendars.any { it.isEventTarget }
if (exportable.isEmpty() && !canImport) {
return BackupUiState.Failure(
calendarListFailure(calendars) ?: FailureReason.NoImportTarget,
)
}
return BackupUiState.Ready(exportable = exportable, canImport = canImport)
}
@@ -90,7 +90,6 @@ import de.jeanlucmakiola.calendula.ui.common.groupByAccount
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.eventFill import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.calendula.ui.common.SourceLogo import de.jeanlucmakiola.calendula.ui.common.SourceLogo
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
@@ -98,25 +97,11 @@ import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.identity.collapseExit import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.heightIn
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.Check
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedSurface
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */ /** Sentinel [editorId] meaning "the editor is composing a new calendar". */
private const val NEW_CALENDAR_ID = Long.MIN_VALUE private const val NEW_CALENDAR_ID = Long.MIN_VALUE
@@ -133,7 +118,6 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
fun CalendarsScreen( fun CalendarsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onOpenBackup: () -> Unit, onOpenBackup: () -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(), viewModel: CalendarsViewModel = hiltViewModel(),
) { ) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle() val calendars by viewModel.calendars.collectAsStateWithLifecycle()
@@ -178,7 +162,6 @@ fun CalendarsScreen(
onConsumeError = viewModel::consumeError, onConsumeError = viewModel::consumeError,
onOpenBackup = onOpenBackup, onOpenBackup = onOpenBackup,
onBack = onBack, onBack = onBack,
visible = visible,
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
onEdit = { calendar -> editorSession++; editorId = calendar.id }, onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetVisible = viewModel::setCalendarVisible, onSetVisible = viewModel::setCalendarVisible,
@@ -195,7 +178,6 @@ private fun CalendarsList(
onConsumeError: () -> Unit, onConsumeError: () -> Unit,
onOpenBackup: () -> Unit, onOpenBackup: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
visible: Boolean,
onAdd: () -> Unit, onAdd: () -> Unit,
onEdit: (CalendarSource) -> Unit, onEdit: (CalendarSource) -> Unit,
onSetVisible: (Long, Boolean) -> Unit, onSetVisible: (Long, Boolean) -> Unit,
@@ -221,7 +203,6 @@ private fun CalendarsList(
onBack = onBack, onBack = onBack,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true, predictiveBack = true,
visible = visible,
) { ) {
// What the per-calendar / per-account switches below actually do. // What the per-calendar / per-account switches below actually do.
HintText(stringResource(R.string.calendars_visibility_hint)) HintText(stringResource(R.string.calendars_visibility_hint))
@@ -250,7 +231,7 @@ private fun CalendarsList(
local.forEachIndexed { index, calendar -> local.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem val disabled = !calendar.isVisibleInSystem
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName,
summary = calendarRowSummary(calendar), summary = calendarRowSummary(calendar),
position = if (index == local.lastIndex) Position.Bottom else Position.Middle, position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest, container = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -258,7 +239,7 @@ private fun CalendarsList(
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = { trailing = {
EnableSwitch( EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, calendarName = calendar.displayName,
enabled = !disabled, enabled = !disabled,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) }, onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
) )
@@ -335,7 +316,7 @@ private fun CalendarsList(
ordered.forEachIndexed { index, calendar -> ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName,
summary = calendarRowSummary(calendar), summary = calendarRowSummary(calendar),
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle, position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest, container = MaterialTheme.colorScheme.surfaceContainerHighest,
@@ -344,7 +325,7 @@ private fun CalendarsList(
trailing = if (calendar.hasVisibilitySwitch) { trailing = if (calendar.hasVisibilitySwitch) {
{ {
EnableSwitch( EnableSwitch(
calendarName = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, calendarName = calendar.displayName,
enabled = calendar.isVisibleInSystem, enabled = calendar.isVisibleInSystem,
onToggle = { enabled -> onToggle = { enabled ->
onSetVisible(calendar.id, enabled) onSetVisible(calendar.id, enabled)
@@ -362,15 +343,9 @@ private fun CalendarsList(
} }
private const val SWATCHES_PER_ROW = 6 @OptIn(ExperimentalMaterial3Api::class)
/**
* Create or edit a local calendar: a name field over the palette of calendar
* colours, on the family's full-screen sheet with the commit in its title bar.
* The same sheet Agendula edits a task list in, so the two read as one product.
*/
@Composable @Composable
internal fun CalendarEditor( private fun CalendarEditor(
sessionKey: Int, sessionKey: Int,
isNew: Boolean, isNew: Boolean,
initialName: String, initialName: String,
@@ -385,50 +360,121 @@ internal fun CalendarEditor(
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) } var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) } var description by rememberSaveable(sessionKey) { mutableStateOf(initialDescription) }
var confirmDelete by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() } val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val valid = name.isNotBlank() Scaffold(
val commit = { if (valid) onSave(name.trim(), color, description.trim().ifEmpty { null }) } modifier = Modifier
.predictiveBack(onBack = onClose)
FullScreenPicker( .fillMaxSize()
title = stringResource( .background(MaterialTheme.colorScheme.surface),
if (isNew) R.string.calendars_new_title else R.string.calendars_edit_title, topBar = {
), TopAppBar(
onDismiss = onClose, title = {
predictiveBack = true, Text(
actions = { stringResource(
Button( if (isNew) R.string.calendars_new_title
onClick = commit, else R.string.calendars_edit_title,
enabled = valid, ),
modifier = Modifier.padding(end = 12.dp), )
) { Text(stringResource(R.string.event_edit_save)) } },
navigationIcon = {
IconButton(onClick = onClose) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.event_edit_close),
)
}
},
actions = {
if (!isNew) {
// Disabled rather than hidden while the special-dates
// sync owns this calendar; the card below says why.
IconButton(
onClick = { confirmDelete = true },
enabled = !deleteLocked,
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
tint = if (deleteLocked) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.error
},
)
}
}
// Filled save button, matching the event editor's top bar.
Button(
onClick = {
onSave(name.trim(), color, description.trim().ifEmpty { null })
},
enabled = name.isNotBlank(),
modifier = Modifier.padding(end = 12.dp),
) {
Text(stringResource(R.string.event_edit_save))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
}, },
) { ) { innerPadding ->
if (deleteLocked) { Column(
ManagedNote() modifier = Modifier
Spacer(Modifier.height(20.dp)) .padding(innerPadding)
} .fillMaxSize()
.verticalScroll(rememberScrollState())
CalendarNameField( .padding(horizontal = 16.dp, vertical = 8.dp),
name = name, verticalArrangement = Arrangement.spacedBy(12.dp),
color = color, ) {
focusRequester = focusRequester, if (deleteLocked) {
onNameChange = { name = it }, EditorCard(
onImeAction = commit, icon = Icons.Default.Info,
) iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
// Inside the picker: it is a Dialog, so a request made from the caller's iconAtTop = true,
// composition can run before this field's node exists. ) {
// A new calendar opens with the keyboard up: naming it is the whole task. Text(
LaunchedEffect(isNew) { if (isNew) focusRequester.requestFocus() } text = stringResource(R.string.calendars_managed_delete_locked),
style = MaterialTheme.typography.bodyMedium,
Spacer(Modifier.height(20.dp)) color = MaterialTheme.colorScheme.onSurfaceVariant,
EditorSectionLabel(stringResource(R.string.calendars_color_label)) )
CalendarColorGrid(selected = color, onSelect = { color = it }) }
}
Spacer(Modifier.height(20.dp)) EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
EditorSectionLabel(stringResource(R.string.calendars_description_label)) InlineTextField(
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) { value = name,
Box(Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(16.dp)) { onValueChange = { name = it },
placeholder = stringResource(R.string.calendars_name_label),
textStyle = MaterialTheme.typography.titleLarge,
capitalization = KeyboardCapitalization.Sentences,
)
}
EditorCard(
icon = Icons.Default.Palette,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
Text(
text = stringResource(R.string.calendars_color_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
ColorSwatchRow(
colors = CalendarColorPalette.all,
selected = color,
onSelect = { color = it },
dark = dark,
)
}
EditorCard(
icon = Icons.AutoMirrored.Filled.Notes,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
InlineTextField( InlineTextField(
value = description, value = description,
onValueChange = { description = it }, onValueChange = { description = it },
@@ -439,21 +485,20 @@ internal fun CalendarEditor(
) )
} }
} }
if (!isNew && !deleteLocked) {
Spacer(Modifier.height(24.dp))
DeleteCalendarRow(onClick = { confirmDelete = true })
}
Spacer(Modifier.height(24.dp))
} }
if (confirmDelete) { if (confirmDelete) {
AlertDialog( AlertDialog(
onDismissRequest = { confirmDelete = false }, onDismissRequest = { confirmDelete = false },
title = { Text(stringResource(R.string.calendars_delete_confirm_title)) }, title = { Text(stringResource(R.string.calendars_delete_confirm_title)) },
text = { Text(stringResource(R.string.calendars_delete_confirm_message, initialName)) }, text = {
Text(stringResource(R.string.calendars_delete_confirm_message, initialName))
},
confirmButton = { confirmButton = {
TextButton(onClick = { confirmDelete = false; onDelete() }) { TextButton(onClick = {
confirmDelete = false
onDelete()
}) {
Text( Text(
stringResource(R.string.event_detail_delete), stringResource(R.string.event_detail_delete),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
@@ -469,205 +514,6 @@ internal fun CalendarEditor(
} }
} }
/** The name, with the chosen colour beside it so the two read as one thing. */
@Composable
private fun CalendarNameField(
name: String,
color: Int,
focusRequester: FocusRequester,
onNameChange: (String) -> Unit,
onImeAction: () -> Unit,
) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.CalendarMonth,
contentDescription = null,
tint = eventAccent(color, dark, soften),
modifier = Modifier.size(22.dp),
)
}
InlineTextField(
value = name,
onValueChange = onNameChange,
placeholder = stringResource(R.string.calendars_name_label),
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Done,
onImeAction = onImeAction,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
}
}
}
/**
* The palette as two rows of round swatches; the chosen one carries a check.
*
* Drawn through the app's own tone pass rather than raw, so a swatch is the
* colour the calendar's events will actually appear in — the user's "soften
* colours" preference included.
*/
@Composable
private fun CalendarColorGrid(selected: Int, onSelect: (Int) -> Unit) {
val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
GroupedSurface(position = Position.Alone, modifier = Modifier.padding(horizontal = 16.dp)) {
Column(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 16.dp)
.selectableGroup(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
CalendarColorPalette.all.chunked(SWATCHES_PER_ROW).forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
row.forEach { swatch ->
ColorSwatch(
fill = eventFill(swatch, dark, soften),
label = stringResource(swatchLabel(swatch)),
selected = swatch == selected,
onClick = { onSelect(swatch) },
modifier = Modifier.weight(1f),
)
}
// Keeps a short final row's swatches the size a full row's
// are, rather than stretching them across the width.
repeat(SWATCHES_PER_ROW - row.size) { Spacer(Modifier.weight(1f)) }
}
}
}
}
}
@Composable
private fun ColorSwatch(
fill: Color,
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.aspectRatio(1f)
.clip(CircleShape)
.background(fill)
.then(
if (selected) {
Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
} else {
Modifier
},
)
// selectable, not clickable: the swatch carries its chosen state in
// semantics too, so the check is not the only cue.
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center,
) {
if (selected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = eventInk(fill, alpha = 0.7f),
modifier = Modifier.size(22.dp),
)
}
}
}
/** Names the swatch for a screen reader — twelve circles are otherwise one label. */
@StringRes
private fun swatchLabel(argb: Int): Int = when (argb) {
CalendarColorPalette.Mauve -> R.string.color_name_mauve
CalendarColorPalette.Red -> R.string.color_name_red
CalendarColorPalette.Orange -> R.string.color_name_orange
CalendarColorPalette.Amber -> R.string.color_name_amber
CalendarColorPalette.Olive -> R.string.color_name_olive
CalendarColorPalette.Green -> R.string.color_name_green
CalendarColorPalette.Teal -> R.string.color_name_teal
CalendarColorPalette.Cyan -> R.string.color_name_cyan
CalendarColorPalette.Blue -> R.string.color_name_blue
CalendarColorPalette.Indigo -> R.string.color_name_indigo
CalendarColorPalette.Purple -> R.string.color_name_purple
else -> R.string.color_name_pink
}
/** Said in the sheet rather than on a disabled button: the sync owns this one. */
@Composable
private fun ManagedNote() {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(Icons.Default.Info, contentDescription = null)
Text(
text = stringResource(R.string.calendars_managed_delete_locked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun DeleteCalendarRow(onClick: () -> Unit) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = 16.dp),
onClick = onClick,
color = MaterialTheme.colorScheme.errorContainer,
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
text = stringResource(R.string.event_detail_delete),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
}
@Composable
private fun EditorSectionLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, bottom = 8.dp),
)
}
/** /**
* The row's supporting line: the states that make this calendar behave unlike a * The row's supporting line: the states that make this calendar behave unlike a
* plain writable one (#76), then its own description. * plain writable one (#76), then its own description.
@@ -710,6 +556,36 @@ private fun EnableSwitch(
private fun dimIf(disabled: Boolean): Modifier = private fun dimIf(disabled: Boolean): Modifier =
if (disabled) Modifier.alpha(0.38f) else Modifier if (disabled) Modifier.alpha(0.38f) else Modifier
/** Tonal field card matching the event editor's design (icon + content). */
@Composable
private fun EditorCard(
icon: ImageVector,
iconTint: Color,
iconAtTop: Boolean = false,
content: @Composable () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconTint,
modifier = Modifier
.padding(top = if (iconAtTop) 2.dp else 0.dp)
.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { content() }
}
}
}
/** /**
* One collapsible calendar group rendered as a connected card. The header row is * One collapsible calendar group rendered as a connected card. The header row is
@@ -14,7 +14,6 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,7 +24,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -58,22 +56,6 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(), initialValue = emptyList(),
) )
/**
* The Backup & restore screen's own view of that list, with the loading and
* failure states [calendars] flattens away — it starts empty and catches to
* empty, which that screen used to render as a blank page (#304).
*/
val backupState: StateFlow<BackupUiState> =
repository.calendars()
.map { backupUiState(it) }
.catch { emit(BackupUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = BackupUiState.Loading,
)
/** Automatic-backup settings + last-run status, for the Backup section UI. */ /** Automatic-backup settings + last-run status, for the Backup section UI. */
val autoBackup: StateFlow<AutoBackupUiState> = combine( val autoBackup: StateFlow<AutoBackupUiState> = combine(
settingsPrefs.autoBackupEnabled, settingsPrefs.autoBackupEnabled,
@@ -56,14 +56,12 @@ fun List<CalendarSource>.groupByAccount(): List<CalendarAccountGroup> {
* account comes from when another account shares the name (#77). * account comes from when another account shares the name (#77).
*/ */
@Composable @Composable
fun accountGroupTitle(group: CalendarAccountGroup): String { fun accountGroupTitle(group: CalendarAccountGroup): String =
val label = group.label.ifBlank { stringResource(R.string.calendar_unnamed) } if (!group.ambiguous) {
return if (!group.ambiguous) { group.label
label
} else { } else {
stringResource(R.string.calendars_account_from_source, label, sourceAppName(group.accountType)) stringResource(R.string.calendars_account_from_source, group.label, sourceAppName(group.accountType))
} }
}
/** /**
* The human name of the app backing [accountType], falling back to the raw * The human name of the app backing [accountType], falling back to the raw
@@ -1,41 +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
/** M3's icon slot inside an icon button; the today glyph fills the same box. */
internal 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
}
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.Crossfade 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.animateFloatAsState
import androidx.compose.animation.core.snap import androidx.compose.animation.core.snap
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -11,240 +10,24 @@ 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.layout.Layout import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
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
import kotlin.math.roundToInt
/** 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
/** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.dp
/**
* What a timed block of a given height has to spend on text, and what each line
* of it costs.
*/
@Immutable
data class BlockTextMetrics(
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
val inset: Dp,
/** Height left for text once [inset] is paid at both edges. */
val available: Dp,
/** What the first line of a title draws in. */
val titleLine: Dp,
/** What every title line after the first adds. */
val titleLeading: Dp,
/** What the time label's one line draws in. */
val timeLine: Dp,
) {
/** Whether the block can draw a title at all. */
val fitsTitle: Boolean get() = available >= titleLine
/** Height a title of [lines] lines occupies. */
fun titleHeight(lines: Int): Dp =
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
/** Title lines that fit [within], which may be none. */
fun titleBudget(within: Dp): Int =
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
}
/**
* The vertical padding a block [height] tall can afford around a title line of
* [titleLine].
*
* The inset is what the block gives up first: breathing room is worth having
* where there is room to breathe, but on a block down to its last few pixels a
* bare colour chip where a label would have fit reads as a rendering fault. It
* tapers rather than snapping, so a pinch closes the gap gradually instead of
* dropping it in one frame (#289).
*/
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
/** Text metrics for a timed block [height] tall. */
@Composable
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
val titleStyle = MaterialTheme.typography.labelMedium
val titleLine = rememberTrimmedLineHeight(titleStyle)
// Packed, so a second line costs what the first did rather than a whole
// Material line box — the gap between two lines of a wrapped title is the
// one place a block pays that leading twice (#190).
val titleLeading = titleLine
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
val inset = blockTextInset(height, titleLine)
return BlockTextMetrics(
inset = inset,
available = height - inset * 2,
titleLine = titleLine,
titleLeading = titleLeading,
timeLine = timeLine,
)
}
/**
* 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:30–11: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)
}
}
}
/**
* Lines the time label may take at [textWidth], out of the [spare] height left
* once the title and the label's own first line are paid for.
*
* A week column is narrower than a "09:30–11:00" range — and a 12-hour
* "6:00 PM–7:00 PM" can want four lines — so the label wraps over as many as it
* needs rather than lose its end, but only out of lines the title measured
* itself as not needing, never one it would have filled.
*/
@Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = rememberTrimmedLineHeight(
MaterialTheme.typography.labelSmall.asEventTime(),
)
return blockTextLines(
text = label,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = timeLinesWithin(spare, timeLineHeight),
)
}
/** Lines a time label may take: its own first one, plus every whole [timeLine] in [spare]. */
internal fun timeLinesWithin(spare: Dp, timeLine: Dp): Int =
if (spare < timeLine || timeLine <= 0.dp) 1 else 1 + (spare / timeLine).toInt()
/**
* The time range as a timed block draws it.
*
* Each time stays whole where the column is wide enough for one, so a 12-hour
* label never parts "6:00" from its "PM": the range breaks at the dash, which
* rides on whichever side has room for it. Where not even one time fits, the
* text wraps wherever it can.
*/
@Composable
fun rememberBlockTimeRange(start: String, end: String, textWidth: Dp): String {
val measurer = rememberTextMeasurer()
val style = MaterialTheme.typography.labelSmall.asEventTime()
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
return remember(start, end, style, widthPx, measurer) {
blockTimeRange(start, end) {
measurer.measure(text = it, style = style, softWrap = false).size.width <= widthPx
}
}
}
internal fun blockTimeRange(start: String, end: String, fits: (String) -> Boolean): String {
val from = start.replace(' ', NO_BREAK_SPACE)
val to = end.replace(' ', NO_BREAK_SPACE)
return when {
fits("$from–$to") -> "$from–$to"
!fits(from) || !fits(to) -> "$start–$ZERO_WIDTH_SPACE$end"
fits("$from–") -> "$from–\n$to"
fits("–$to") -> "$from\n–$to"
else -> "$from\n$to"
}
}
private const val NO_BREAK_SPACE = ' '
private const val ZERO_WIDTH_SPACE = '​'
/**
* A timed block's title, over at most [maxLines], breaking at word boundaries.
*
* #164 clipped the last line mid-glyph so none of a narrow chip's few
* characters went on an ellipsis. On a block that can wrap, whole words read
* better than full lines do: "Farmers Market" over two lines beats "Farmer" /
* "s Marke". A single line still clips, having nowhere to wrap to.
*/
@Composable
fun BlockTitle(
title: String,
maxLines: Int,
color: Color,
modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null,
fontWeight: FontWeight? = null,
) {
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Text(
text = title,
modifier = modifier,
style = rememberPackedLines(
MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
),
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.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)
// Regular weight against the title's medium above it (#219).
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
Crossfade( Crossfade(
targetState = label, targetState = label,
animationSpec = spec, animationSpec = spec,
@@ -253,22 +36,17 @@ fun BlockTimeLabel(
) { text -> ) { text ->
Text( Text(
text = text, text = text,
style = style, style = MaterialTheme.typography.labelSmall,
maxLines = maxLines, maxLines = 1,
overflow = overflow.overflow, overflow = TextOverflow.Ellipsis,
softWrap = overflow.softWrap,
color = color, color = color,
) )
} }
} }
/** /** Where a timed block sits in its column, after tweening. */
* Where a timed block sits in its column, after tweening: across as shares of
* the column's width, so a column resizing carries its blocks with it (#184),
* down in the timeline's own units.
*/
@Immutable @Immutable
data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y: Dp, val height: Dp) data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
/** /**
* A timed block's placement, tweened rather than jumped. Continuity comes from * A timed block's placement, tweened rather than jumped. Continuity comes from
@@ -281,49 +59,17 @@ data class BlockPlacement(val xFraction: Float, val widthFraction: Float, val y:
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun animatedBlockPlacement(xFraction: Float, widthFraction: Float, y: Dp, height: Dp): BlockPlacement { fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
val still = rememberReduceMotion() || LocalTimelineZoom.current.isPinching val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
val dpSpec: FiniteAnimationSpec<Dp> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() LocalTimelineZoom.current.isPinching
val shareSpec: FiniteAnimationSpec<Float> = if (still) snap() else MaterialTheme.motionScheme.fastSpatialSpec() ) {
val animatedX by animateFloatAsState(xFraction, shareSpec, label = "block-x") snap()
val animatedWidth by animateFloatAsState(widthFraction, shareSpec, label = "block-width") } else {
val animatedY by animateDpAsState(y, dpSpec, label = "block-y") MaterialTheme.motionScheme.fastSpatialSpec()
val animatedHeight by animateDpAsState(height, dpSpec, label = "block-height")
return BlockPlacement(animatedX, animatedWidth, animatedY, animatedHeight)
}
/** Seats a block across its [placement]'s share of a [LaneColumn]. */
fun Modifier.laneSlot(placement: BlockPlacement): Modifier =
then(LaneSlot(placement.xFraction, placement.widthFraction))
private data class LaneSlot(val x: Float, val width: Float) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = this@LaneSlot
}
/**
* A day column's blocks, each measured across the share of the column its
* [laneSlot] gives it; anything else fills the column. The shares are applied
* at layout time, so the column can change width every frame without its
* blocks recomposing.
*/
@Composable
fun LaneColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val width = constraints.maxWidth
val loose = constraints.copy(minWidth = 0, minHeight = 0)
val placed = measurables.map { measurable ->
val slot = measurable.parentData as? LaneSlot
if (slot == null) {
measurable.measure(loose) to 0
} else {
val left = (slot.x * width).roundToInt()
val right = ((slot.x + slot.width) * width).roundToInt()
val laneWidth = (right - left).coerceAtLeast(0)
measurable.measure(loose.copy(minWidth = laneWidth, maxWidth = laneWidth)) to left
}
}
layout(width, constraints.maxHeight) {
placed.forEach { (placeable, x) -> placeable.placeRelative(x, 0) }
}
} }
val animatedX by animateDpAsState(x, spec, label = "block-x")
val animatedY by animateDpAsState(y, spec, label = "block-y")
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
} }
@@ -1,161 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalDate
/**
* What a calendar screen shows in the top bar, the FAB and the drawer, which the
* host draws once for all of them (#184): switching screens changes what they
* say rather than swapping one bar for another.
*
* @param titleDate the date the title's picker opens on, or null for a title
* that is only a label.
* @param drawerDate the date the drawer's picker opens on.
* @param awayFromToday whether "today" is worth offering.
*/
@Immutable
data class ScreenChrome(
val title: String,
val shortTitle: String = title,
val titleDate: LocalDate?,
val drawerDate: LocalDate,
val awayFromToday: Boolean,
val todayText: String,
)
/** What the chrome does for the screen on show; read when used, so not observed. */
class ScreenActions(
val onToday: () -> Unit,
val onJumpToDate: (LocalDate) -> Unit,
val onCreate: () -> Unit,
)
/** The screen on show's [ScreenChrome] and [ScreenActions], for the host to draw. */
@Stable
class ChromeHost {
var chrome by mutableStateOf<ScreenChrome?>(null)
private set
var actions: ScreenActions? = null
private set
internal fun publish(chrome: ScreenChrome, actions: ScreenActions) {
this.chrome = chrome
this.actions = actions
}
}
/** The host's chrome, and whether the screen reading it is the one on show rather than one leaving. */
class ChromeSlot(val host: ChromeHost, val current: Boolean)
val LocalChromeSlot = staticCompositionLocalOf<ChromeSlot?> { null }
/** Hands [chrome] and [actions] to the host, while this screen is the one on show. */
@Composable
fun PublishChrome(chrome: ScreenChrome, actions: ScreenActions) {
val slot = LocalChromeSlot.current ?: return
if (slot.current) SideEffect { slot.host.publish(chrome, actions) }
}
/** The top bar every calendar screen shares. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarTopBar(
host: ChromeHost,
selectedView: CalendarView,
quickSwitchViews: List<CalendarView>,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
showTodayButton: Boolean,
) {
val chrome = host.chrome
TopAppBar(
title = {
if (chrome != null) {
CalendarTitleButton(
title = chrome.title,
currentDate = chrome.titleDate,
onJumpToDate = { host.actions?.onJumpToDate?.invoke(it) },
shortTitle = chrome.shortTitle,
)
}
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = { host.actions?.onToday?.invoke() })
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
cycle = quickSwitchViews,
onCycle = onCycleView,
)
},
// 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(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
/** The FAB column every calendar screen shares. */
@Composable
fun CalendarFab(host: ChromeHost, todayInToolbar: Boolean) {
val chrome = host.chrome ?: return
CalendarFabColumn(
todayVisible = chrome.awayFromToday && !todayInToolbar,
todayText = chrome.todayText,
onToday = { host.actions?.onToday?.invoke() },
onCreate = { host.actions?.onCreate?.invoke() },
)
}
/**
* A calendar screen's body under the shared chrome, on the ground the screen's
* own Scaffold used to give it; the host's Scaffold has already made room for
* the bars, so [content] gets no padding of its own.
*/
@Composable
fun ScreenBody(modifier: Modifier = Modifier, content: @Composable (PaddingValues) -> Unit) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
content(PaddingValues())
}
}
@@ -91,7 +91,7 @@ fun CalendarDrawer(
DrawerSectionHeader(stringResource(R.string.view_section)) DrawerSectionHeader(stringResource(R.string.view_section))
viewOrder.forEachIndexed { index, view -> viewOrder.forEachIndexed { index, view ->
GroupedRow( GroupedRow(
title = view.label(), title = stringResource(view.labelRes),
position = positionOf(index, viewOrder.size), position = positionOf(index, viewOrder.size),
selected = view == currentView, selected = view == currentView,
minHeight = 56.dp, minHeight = 56.dp,
@@ -1,40 +1,17 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import android.provider.Settings
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.EditOff
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.EventBusy
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.SyncProblem
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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
@@ -43,168 +20,37 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
/** /**
* Full-screen failure state shared by every calendar screen (spec §7). * Full-screen failure state shared by every calendar screen (spec §7).
* A tonal icon, one headline, one supporting line and one recovery action — * One explanation line + one recovery action, never a toast.
* never a toast.
*/ */
@Composable @Composable
fun CalendarFailure( fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
reason: FailureReason, val titleRes = when (reason) {
onRetry: () -> Unit, FailureReason.PermissionRevoked -> R.string.state_failure_permission
modifier: Modifier = Modifier, FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
) { FailureReason.ProviderUnavailable -> R.string.state_failure_provider
val context = LocalContext.current FailureReason.Unknown,
val manageCalendars = LocalManageCalendars.current FailureReason.EventNotFound -> R.string.state_failure_unknown
val copy = failureCopy(reason) }
val onAction: () -> Unit = when (reason) { val actionRes = when (reason) {
FailureReason.NoCalendarsConfigured -> { FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
{ context.startCalendarSetup() } FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
} else -> R.string.state_retry
FailureReason.AllCalendarsHidden,
FailureReason.NoImportTarget,
-> manageCalendars ?: onRetry
else -> onRetry
} }
Column( Column(
modifier = modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 32.dp, vertical = 24.dp), .padding(32.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
FailureIcon(icon = copy.icon, isError = copy.isError)
Spacer(Modifier.height(24.dp))
Text( Text(
text = stringResource(copy.title), text = stringResource(titleRes),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
) )
copy.body?.let { body -> Spacer(Modifier.height(24.dp))
Spacer(Modifier.height(12.dp)) FilledTonalButton(onClick = onRetry) {
Text( Text(stringResource(actionRes))
text = stringResource(body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = TEXT_MAX_WIDTH),
)
}
Spacer(Modifier.height(32.dp))
FilledTonalButton(onClick = onAction) {
Text(stringResource(copy.action))
} }
} }
} }
/** The state's mark: the reason's icon in a tonal circle above the headline. */
@Composable
private fun FailureIcon(icon: ImageVector, isError: Boolean) {
val container: Color
val content: Color
if (isError) {
container = MaterialTheme.colorScheme.errorContainer
content = MaterialTheme.colorScheme.onErrorContainer
} else {
container = MaterialTheme.colorScheme.secondaryContainer
content = MaterialTheme.colorScheme.onSecondaryContainer
}
Box(
modifier = Modifier
.size(72.dp)
.background(container, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = content,
modifier = Modifier.size(36.dp),
)
}
}
/**
* What a reason says and offers. Headline and supporting line are separate so
* the explanation reads as body text instead of a headline that runs over three
* lines; [isError] tints the mark for the two reasons that are an actual fault
* rather than a calendar the user can switch back on.
*/
private data class FailureCopy(
val icon: ImageVector,
@StringRes val title: Int,
@StringRes val body: Int?,
@StringRes val action: Int,
val isError: Boolean = false,
)
private fun failureCopy(reason: FailureReason): FailureCopy = when (reason) {
FailureReason.PermissionRevoked -> FailureCopy(
icon = Icons.Outlined.Lock,
title = R.string.state_failure_permission,
body = R.string.state_failure_permission_body,
action = R.string.state_failure_permission_action,
)
FailureReason.NoCalendarsConfigured -> FailureCopy(
icon = Icons.Outlined.CalendarMonth,
title = R.string.state_failure_no_calendars,
body = R.string.state_failure_no_calendars_body,
action = R.string.state_failure_no_calendars_action,
)
FailureReason.AllCalendarsHidden -> FailureCopy(
icon = Icons.Outlined.VisibilityOff,
title = R.string.state_failure_all_hidden,
body = R.string.state_failure_all_hidden_body,
action = R.string.state_failure_all_hidden_action,
)
FailureReason.NoImportTarget -> FailureCopy(
icon = Icons.Outlined.EditOff,
title = R.string.state_failure_no_import_target,
body = R.string.state_failure_no_import_target_body,
action = R.string.state_failure_all_hidden_action,
)
FailureReason.EventNotFound -> FailureCopy(
icon = Icons.Outlined.EventBusy,
title = R.string.state_failure_event_not_found,
body = R.string.state_failure_event_not_found_body,
action = R.string.state_retry,
)
FailureReason.ProviderUnavailable -> FailureCopy(
icon = Icons.Outlined.SyncProblem,
title = R.string.state_failure_provider,
body = R.string.state_failure_provider_body,
action = R.string.state_retry,
isError = true,
)
FailureReason.Unknown -> FailureCopy(
icon = Icons.Outlined.ErrorOutline,
title = R.string.state_failure_unknown,
body = null,
action = R.string.state_retry,
isError = true,
)
}
/** Keeps the headline and its supporting line at a readable measure. */
private val TEXT_MAX_WIDTH = 320.dp
/**
* Opens Settings → Calendars, the only screen that can switch a calendar back
* on. Null outside the calendar host — screens without it never raise the
* reason that needs it, and fall back to their own retry.
*/
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
/**
* Open the system screen for putting a calendar on the device: the account
* chooser filtered to calendar providers, falling back to account settings
* where no chooser resolves. Both are system components, so nothing here needs
* a `<queries>` entry.
*/
private fun Context.startCalendarSetup() {
val intents = listOf(
Intent(Settings.ACTION_ADD_ACCOUNT)
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
Intent(Settings.ACTION_SYNC_SETTINGS),
)
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
}
@@ -1,57 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.lerp
import kotlinx.coroutines.flow.collectLatest
import kotlin.math.floor
/**
* One height for every page of something that varies from page to page — the
* timelines' all-day strip, the split grid's rows — blended between the two
* pages a swipe sits between, so what sits below it moves with the finger rather
* than jumping on settle. It follows the swipe directly and only springs when a
* page's own height changes at rest. [fallback] stands in until the pages have
* reported theirs, so it doesn't open from zero.
*
* @param heights each composed page's own height, by page index.
*/
@Composable
fun pagedHeight(pagerState: PagerState, heights: Map<Int, Dp>, fallback: Dp): Dp {
val currentFallback by rememberUpdatedState(fallback)
val target by remember(pagerState, heights) {
derivedStateOf {
val position = pagerState.currentPage + pagerState.currentPageOffsetFraction
val from = floor(position).toInt()
val a = heights[from]
val b = heights[from + 1]
lerp(a ?: b ?: currentFallback, b ?: a ?: currentFallback, position - from)
}
}
val height = remember { Animatable(target, Dp.VectorConverter) }
LaunchedEffect(height) {
snapshotFlow { target to pagerState.isScrollInProgress }.collectLatest { (h, scrolling) ->
if (scrolling) height.snapTo(h) else height.animateTo(h)
}
}
return height.value
}
/** A deliberate jump (Today, jump-to-date): animated, or snapped under reduced motion. */
suspend fun PagerState.jumpToPage(
page: Int,
reduceMotion: Boolean,
spec: FiniteAnimationSpec<Float>,
) {
if (reduceMotion) scrollToPage(page) else animateScrollToPage(page, animationSpec = spec)
}
@@ -116,7 +116,7 @@ private fun CalendarPickerGroup(
calendars.forEachIndexed { index, calendar -> calendars.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId val isSelected = calendar.id == selectedId
GroupedRow( GroupedRow(
title = calendar.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = calendar.displayName,
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle, position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
selected = isSelected, selected = isSelected,
leading = { CalendarColorChip(calendar.color) }, leading = { CalendarColorChip(calendar.color) },
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* Drag distance that turns a calendar page, shared by the month, week and day
* views so all three answer a swipe at the same point.
*
* It was 6dp once, which is inside the distance a tap wanders: brushing the grid
* changed the month, and a page that turns on an unintended gesture reads as the
* animation misfiring rather than as the gesture being over-eager.
*/
val CALENDAR_SWIPE_THRESHOLD = 24.dp
/**
* The whole-page horizontal swipe: one page per gesture, committed **the moment
* the drag clears [CALENDAR_SWIPE_THRESHOLD]** rather than when the finger lifts.
*
* Waiting for the lift meant the page sat still under a finger that had already
* travelled far enough to ask for it, and the answer only arrived once you let
* go — which reads as the view being slow rather than as a deliberate commit.
* Firing on the threshold is what makes the gesture feel like it is being
* followed. The trade is that a drag can no longer be taken back by dragging the
* other way; in practice, once you have moved 24dp deliberately you meant it, and
* the page you land on is one swipe back.
*
* Deliberately **horizontal-only**. The week and day timelines scroll vertically
* underneath this, and a two-dimensional detector here would claim those drags
* before the inner scroll ever saw them. As it is, a horizontal drag crosses this
* detector's slop while a vertical one is consumed below, and the two coexist.
* (The month view's split style needs a vertical axis as well, so it keeps its
* own axis-locking detector rather than using this.)
*/
@Composable
fun rememberCalendarPageSwipe(
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
): Modifier {
val threshold = with(LocalDensity.current) { CALENDAR_SWIPE_THRESHOLD.toPx() }
return Modifier.pointerInput(onSwipeNext, onSwipePrev) {
var accum = 0f
// One page per gesture: without this a long drag would keep re-firing
// every time the accumulator crossed the threshold again.
var fired = false
detectHorizontalDragGestures(
onDragStart = {
accum = 0f
fired = false
},
onDragEnd = {
accum = 0f
fired = false
},
onDragCancel = {
accum = 0f
fired = false
},
onHorizontalDrag = { _, drag ->
accum += drag
if (!fired) {
val commit = when {
accum < -threshold -> onSwipeNext
accum > threshold -> onSwipePrev
else -> null
}
if (commit != null) {
fired = true
commit()
}
}
},
)
}
}
@@ -1,57 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
/**
* The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in
* a 16sp line box — room a label wants when it stands on its own, and close to
* a fifth of an event row when it doesn't. The outer edges only, so a wrapped
* title keeps its interior line spacing (#190).
*
* Trimmed rather than set to a smaller line height: the font picker can load a
* serif, a monospace or a file of the user's own, and a line height under a
* face's own ascent and descent overlaps its lines. There is nothing to trim
* below that, so this is safe whatever font is chosen.
*/
private val TrimmedLines = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.Both,
)
/** Ascenders and descenders both, so a line is measured at its full extent. */
private const val LINE_SAMPLE = "Ag"
/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */
fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines)
/**
* [style] with its wrapped lines packed onto the face's own extent instead of
* Material's line box.
*
* [trimmedLines] takes the leading off the outer edges of a run of text; this
* takes it from between the lines as well, which is the half a wrapped event
* title pays for twice over. The line height is *measured* from the font rather
* than picked, so it lands exactly on the face's ascent-plus-descent and can
* never be short enough to overlap — whatever the font picker has loaded (#190).
*/
@Composable
fun rememberPackedLines(style: TextStyle): TextStyle {
val line = rememberTrimmedLineHeight(style)
return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) }
}
/** What one trimmed line of [style] actually draws in. */
@Composable
fun rememberTrimmedLineHeight(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(style, density, measurer) {
with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() }
}
}
@@ -1,15 +1,6 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
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
@@ -18,19 +9,16 @@ import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Icon 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.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.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.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
@@ -41,20 +29,19 @@ import kotlinx.datetime.LocalDate
* drawer uses and reporting the chosen day through [onJumpToDate]. * drawer uses and reporting the chosen day through [onJumpToDate].
* *
* [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); null makes the title a plain label, * visible day, week start or month anchor).
* as the agenda's is, and the caret folds away.
* *
* [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(
title: String, title: String,
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) }
@@ -63,56 +50,25 @@ fun CalendarTitleButton(
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.clip(MaterialTheme.shapes.large) .clip(MaterialTheme.shapes.large)
.clickable( .clickable(
enabled = currentDate != null,
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 Text(
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) { text = title,
val measurer = rememberTextMeasurer() style = MaterialTheme.typography.titleLarge,
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) { modifier = Modifier.weight(1f, fill = false),
titleFor( )
title = title, Icon(
shortTitle = shortTitle, imageVector = Icons.Default.ArrowDropDown,
titleWidth = measurer.measure(title, style).size.width, contentDescription = null,
availableWidth = constraints.maxWidth, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}
// Cross-fades, so switching views or paging turns the title over
// rather than snapping it (#184).
val fadeSpec = rememberCalendarFadeSpec()
AnimatedContent(
targetState = shown,
transitionSpec = {
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
},
contentAlignment = Alignment.CenterStart,
) { text ->
Text(
text = text,
style = style,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
AnimatedVisibility(
visible = currentDate != null,
enter = fadeIn(rememberCalendarFadeSpec()) + expandHorizontally(),
exit = fadeOut(rememberCalendarFadeSpec()) + shrinkHorizontally(),
) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
if (showDatePicker && currentDate != null) { if (showDatePicker) {
CalendarDatePickerDialog( CalendarDatePickerDialog(
initial = currentDate, initial = currentDate,
onConfirm = { onConfirm = {
@@ -123,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
@@ -1,7 +1,13 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring 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.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -13,12 +19,12 @@ import androidx.compose.ui.unit.IntOffset
* the reduce-motion check — now live in floret-kit's identity module * the reduce-motion check — now live in floret-kit's identity module
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`, * (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays * `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
* here is only what's specific to paging the calendar grid: the specs its * here is only what's specific to paging the calendar grid: the directional
* pagers settle with, and that the view switch reuses. * month/week/day slide and the specs that feed it.
*/ */
/** /**
* The M3 Expressive spatial spring the calendar's motion is built on: the * The M3 Expressive spatial spring used for the month/week/day slide: the
* *default* spring-physics spec from the active motion scheme, rather than a * *default* spring-physics spec from the active motion scheme, rather than a
* fixed easing curve. * fixed easing curve.
* *
@@ -34,12 +40,6 @@ import androidx.compose.ui.unit.IntOffset
fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> = fun rememberCalendarSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.defaultSpatialSpec() MaterialTheme.motionScheme.defaultSpatialSpec()
/** The same spring as [rememberCalendarSlideSpec], for a pager settling onto its page. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberCalendarPageSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultSpatialSpec()
/** /**
* The effects spec from the active motion scheme, for the opacity half of the * The effects spec from the active motion scheme, for the opacity half of the
* transition. Captured in composable scope alongside [rememberCalendarSlideSpec] * transition. Captured in composable scope alongside [rememberCalendarSlideSpec]
@@ -55,17 +55,48 @@ fun rememberCalendarFadeSpec(): FiniteAnimationSpec<Float> =
MaterialTheme.motionScheme.defaultEffectsSpec() MaterialTheme.motionScheme.defaultEffectsSpec()
/** /**
* A switch between views (#184): the default spatial spring, a little softer, as * Navigating between adjacent months/weeks/days, as M3's shared-axis X: the
* the whole screen reshapes rather than a page sliding by; softer still when * outgoing page slides and fades one way while the incoming one arrives from the
* [long], for a change the eye has further to follow. * other, position on a spring and opacity on an easing curve.
*
* @param slideDir +1 = forward (incoming from the right), -1 = back, 0 = jump
* (e.g. "today"); a jump reuses the forward direction.
* @param spec spatial animation spec, typically [rememberCalendarSlideSpec].
* @param fadeSpec effects spec for the opacity half, and for the whole
* transition under reduced motion; typically
* [rememberCalendarFadeSpec].
* @param reduceMotion when true, drop the movement and cross-fade alone.
*/ */
@Composable fun calendarSlideTransition(
fun <T> rememberViewSwitchSpec(long: Boolean = false): FiniteAnimationSpec<T> = slideDir: Int,
spring( spec: FiniteAnimationSpec<IntOffset>,
dampingRatio = VIEW_SWITCH_DAMPING, fadeSpec: FiniteAnimationSpec<Float>,
stiffness = if (long) LONG_VIEW_SWITCH_STIFFNESS else VIEW_SWITCH_STIFFNESS, 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 / SLIDE_TRAVEL_DIVISOR } + fadeIn(fadeSpec),
initialContentExit =
slideOutHorizontally(spec) { w -> -dir * w / SLIDE_TRAVEL_DIVISOR } + fadeOut(fadeSpec),
// AnimatedContent clips to the animating container by default, which
// shears the pages against the viewport edge as they pass. There is no
// size change here to contain — both pages are the same grid.
sizeTransform = SizeTransform(clip = false),
) )
}
private const val VIEW_SWITCH_DAMPING = 0.9f /**
private const val VIEW_SWITCH_STIFFNESS = 450f * How far a page travels, as a fraction of the container width.
private const val LONG_VIEW_SWITCH_STIFFNESS = 340f *
* A full width was the obvious reading of "paging", but the two pages are
* stacked and both opaque, so a full-width slide showed one grid racing across
* another — the movement carried the whole transition and had a long way to go.
* Under M3's shared-axis pattern the offset only has to *hint* the direction
* while the cross-fade does the swapping, so a fifth of the width is plenty and
* leaves nothing skating past.
*/
private const val SLIDE_TRAVEL_DIVISOR = 5
@@ -1,53 +1,37 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarViewDay import androidx.compose.material.icons.filled.CalendarViewDay
import androidx.compose.material.icons.filled.CalendarViewMonth import androidx.compose.material.icons.filled.CalendarViewMonth
import androidx.compose.material.icons.filled.CalendarViewWeek import androidx.compose.material.icons.filled.CalendarViewWeek
import androidx.compose.material.icons.filled.ViewAgenda import androidx.compose.material.icons.filled.ViewAgenda
import androidx.compose.material.icons.filled.ViewColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
/** The top-level calendar views the user can switch between (spec M1). */ /** The top-level calendar views the user can switch between (spec M1). */
enum class CalendarView { enum class CalendarView {
Month, Month,
Week, Week,
MultiDay,
Day, Day,
Agenda, Agenda,
} }
/** How many days the multi-day view shows (#193), provided once at the app root. */ /** Switcher label, shared by the top-bar pill and the drawer's View section. */
val LocalMultiDayCount = staticCompositionLocalOf { SettingsPrefs.DEFAULT_MULTI_DAY_COUNT } @get:StringRes
val CalendarView.labelRes: Int
/** get() = when (this) {
* The view's name, shared by the top-bar pill, the drawer and Settings. The CalendarView.Month -> R.string.view_month
* multi-day view is named after its day count ("3 days"). CalendarView.Week -> R.string.view_week
*/ CalendarView.Day -> R.string.view_day
@Composable CalendarView.Agenda -> R.string.view_agenda
fun CalendarView.label(): String = when (this) {
CalendarView.Month -> stringResource(R.string.view_month)
CalendarView.Week -> stringResource(R.string.view_week)
CalendarView.MultiDay -> {
val count = LocalMultiDayCount.current
pluralStringResource(R.plurals.view_multi_day, count, count)
} }
CalendarView.Day -> stringResource(R.string.view_day)
CalendarView.Agenda -> stringResource(R.string.view_agenda)
}
/** Leading icon for the view in the drawer's View section. */ /** Leading icon for the view in the drawer's View section. */
val CalendarView.icon: ImageVector val CalendarView.icon: ImageVector
get() = when (this) { get() = when (this) {
CalendarView.Month -> Icons.Filled.CalendarViewMonth CalendarView.Month -> Icons.Filled.CalendarViewMonth
CalendarView.Week -> Icons.Filled.CalendarViewWeek CalendarView.Week -> Icons.Filled.CalendarViewWeek
CalendarView.MultiDay -> Icons.Filled.ViewColumn
CalendarView.Day -> Icons.Filled.CalendarViewDay CalendarView.Day -> Icons.Filled.CalendarViewDay
CalendarView.Agenda -> Icons.Filled.ViewAgenda CalendarView.Agenda -> Icons.Filled.ViewAgenda
} }
@@ -57,13 +41,7 @@ val CalendarView.icon: ImageVector
* through these in order. * through these in order.
*/ */
val IMPLEMENTED_VIEWS: List<CalendarView> = val IMPLEMENTED_VIEWS: List<CalendarView> =
listOf( listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda)
CalendarView.Month,
CalendarView.Week,
CalendarView.MultiDay,
CalendarView.Day,
CalendarView.Agenda,
)
/** Next view in [available], wrapping around. Falls back to Month if absent. */ /** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView { fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
@@ -79,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>,
@@ -90,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
} }
} }
@@ -129,16 +105,3 @@ fun viewBaseStack(default: CalendarView, source: CalendarView): List<CalendarVie
/** Push the day view as a drill-in over the current stack (no-op if already on it). */ /** Push the day view as a drill-in over the current stack (no-op if already on it). */
fun List<CalendarView>.drillToDay(): List<CalendarView> = fun List<CalendarView>.drillToDay(): List<CalendarView> =
if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day if (lastOrNull() == CalendarView.Day) this else this + CalendarView.Day
/** The screens the views are drawn by: the day, multi-day and week views share one timeline (#184). */
enum class ViewScreen { Month, Timeline, Agenda }
val CalendarView.screen: ViewScreen
get() = when (this) {
CalendarView.Month -> ViewScreen.Month
CalendarView.Agenda -> ViewScreen.Agenda
CalendarView.Week, CalendarView.MultiDay, CalendarView.Day -> ViewScreen.Timeline
}
/** Push the week view over the month after a week-number tap. */
fun List<CalendarView>.drillToWeek(): List<CalendarView> =
if (lastOrNull() == CalendarView.Week) this else this + CalendarView.Week
@@ -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
}
}
}
}
@@ -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,
)
@@ -35,12 +35,8 @@ class EventMoveScope(
* accidental drags the setting exists to stop. * accidental drags the setting exists to stop.
*/ */
val dragEnabled: Boolean, val dragEnabled: Boolean,
/** /** False when the drop was refused outright, so nothing will be written. */
* Hand a drop to the writer. Returns nothing on purpose: a drop is answered val move: (MoveRequest) -> Boolean,
* 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 +45,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 +57,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 +67,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.
@@ -1,165 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.isDeclined
/** Stroke an invitation you have not answered is outlined with. */
val EVENT_OUTLINE_WIDTH = 1.dp
/**
* The surface an event chip is drawn on, which an outlined one fills itself with
* so it reads as the background showing through rather than as a pale block laid
* over it. Defaults to the `surfaceContainer` the month cells and the timeline
* columns carry; the all-day strips sit on plain `surface` and a floating drag
* copy wants a lifted one, so both provide their own.
*/
val LocalChipGround = compositionLocalOf { Color.Unspecified }
/**
* Which edges of a chip the event runs past. Those edges are squared by
* [monthBarShape] / [timedBlockShape] so the cut reads as "this carries on", and
* an outline has to leave them open for the same reason — a stroke all the way
* round would close a bar carried across a week boundary into two boxes.
*/
@Immutable
data class ChipCuts(
val start: Boolean = false,
val end: Boolean = false,
val top: Boolean = false,
val bottom: Boolean = false,
)
/** [ChipCuts] for a month or all-day bar, which is only ever cut left and right. */
fun monthBarCuts(continuesLeft: Boolean, continuesRight: Boolean): ChipCuts =
ChipCuts(start = continuesLeft, end = continuesRight)
/** [ChipCuts] for a timed block, which is only ever cut top and bottom. */
fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts =
ChipCuts(top = continuesBefore, bottom = continuesAfter)
/**
* How one event's chip or block is painted, which turns on your answer to its
* invitation (#180, #230).
*
* An invitation you have not answered is drawn as an outline: its calendar's
* colour on the border and on the title, over the surface the chip sits on. So
* it holds a chip's shape and a chip's weight without claiming the filled
* container an event you are going to gets. A declined one keeps the fill and
* its strike-through.
*/
@Immutable
data class EventPaint(
val fill: Color,
/** Null for a filled chip; the border colour when the chip is outlined. */
val outline: Color?,
val titleInk: Color,
val secondaryInk: Color,
val decoration: TextDecoration?,
/**
* Weight to set the title at, or null to keep whatever the surface's own
* text style carries.
*/
val titleWeight: FontWeight?,
)
/** [EventPaint] for [event] on a [dark] scheme. */
@Composable
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
val soften = LocalSoftenColors.current
if (event.response == EventResponse.Invited) {
// Harmonised even when the setting is off. Raw mode exists so a filled
// container matches what the sync source paints, and an outlined chip
// has no container — what it has is coloured *text*, which needs the
// lightness pinned against the surface or a pale calendar goes
// unreadable (eventTone returns the raw colour verbatim otherwise).
val accent = eventAccent(event.color, dark, soften = true)
val ground = LocalChipGround.current.takeIf { it != Color.Unspecified }
?: MaterialTheme.colorScheme.surfaceContainer
return EventPaint(
fill = ground,
outline = accent,
titleInk = accent,
// A neutral token rather than the accent faded: the time is the
// smallest text on the chip, and an alpha step off an accent that is
// itself only just clear of the surface is where legibility goes.
secondaryInk = MaterialTheme.colorScheme.onSurfaceVariant,
decoration = null,
// The label styles' medium weight is set to carry ink on a filled
// container. In a calendar colour on a plain one it thickens into
// something harder to read, so step it back to regular.
titleWeight = FontWeight.Normal,
)
}
val fill = eventFill(event.color, dark, soften)
return EventPaint(
fill = fill,
outline = null,
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
decoration = declinedDecoration(event.isDeclined),
titleWeight = null,
)
}
/** [this] set at the weight [paint] asks a title for, for measuring and for drawing alike. */
fun TextStyle.withTitleWeight(paint: EventPaint): TextStyle =
if (paint.titleWeight == null) this else copy(fontWeight = paint.titleWeight)
/** Seats a chip or block on [shape]: its fill, plus the border when it has one. */
fun Modifier.eventSurface(
paint: EventPaint,
shape: Shape,
cuts: ChipCuts = ChipCuts(),
): Modifier {
val filled = background(paint.fill, shape)
val outline = paint.outline ?: return filled
return filled.clip(shape).drawBehind { drawChipOutline(outline, cuts) }
}
/**
* The border, stroked as one round-rect whose edges run past the chip on every
* side the event continues over. Clipped to the chip's own shape, so those
* strokes — and the corners that would have turned back in — fall outside and
* the edge stays open.
*/
private fun DrawScope.drawChipOutline(color: Color, cuts: ChipCuts) {
val stroke = EVENT_OUTLINE_WIDTH.toPx()
val radius = EVENT_CHIP_CORNER.toPx()
// Far enough out that the corner arc clears the clip too, not just the edge.
val bleed = radius + stroke
val rtl = layoutDirection == LayoutDirection.Rtl
val leftCut = if (rtl) cuts.end else cuts.start
val rightCut = if (rtl) cuts.start else cuts.end
val left = if (leftCut) -bleed else stroke / 2f
val top = if (cuts.top) -bleed else stroke / 2f
val right = if (rightCut) size.width + bleed else size.width - stroke / 2f
val bottom = if (cuts.bottom) size.height + bleed else size.height - stroke / 2f
drawRoundRect(
color = color,
topLeft = Offset(left, top),
size = Size(right - left, bottom - top),
cornerRadius = CornerRadius(radius),
style = Stroke(width = stroke),
)
}
@@ -1,77 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.floret.locale.currentLocale
private val TIME_WEIGHT = FontWeight.Normal
private val TIME_TRACKING = 0.sp
private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
/**
* Ink for an event's title, against [SECONDARY_INK_ALPHA] for the time beside or
* beneath it. Full, so the pair separates on a typeface with no medium weight to
* step down from — Atkinson Hyperlegible and JetBrains Mono ship regular and bold
* only, and a user's imported font a single face, so [asEventTime] is a no-op
* there and the ink is the whole difference (#219).
*/
const val TITLE_INK_ALPHA = 1f
/**
* [this] set as an event's time rather than as its title: the label styles' medium
* weight and 0.5sp of tracking left a time reading as part of the title next to it.
*/
fun TextStyle.asEventTime(): TextStyle =
copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
/** Text reading [time], set apart in [timeInk], before [title]. */
fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString =
buildAnnotatedString {
if (time != null) {
withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) }
append(" ")
}
append(title)
}
/**
* The characters of title that have to survive a time prefix for it to be worth
* its place — lowercase Latin of average advance, priced at the surface's own
* text style rather than guessed in dp.
*/
private const val TITLE_SAMPLE = "notepad"
/** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */
private const val SAMPLE_HOUR = 12
private const val SAMPLE_MINUTE = 45
/**
* The narrowest run of [style] text that may carry a time in front of a title:
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE].
*
* Measured rather than a device breakpoint, so it follows the font scale, the
* 12/24-hour setting, the locale's own time format and whatever else has
* already been taken off the width.
*/
@Composable
fun rememberInlineTimeWidth(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val locale = currentLocale()
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
return remember(sample, style, density, measurer) {
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
with(density) { measurer.measure(label, style).size.width.toDp() }
}
}
@@ -1,37 +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 instead — mid-glyph on one line, at the last whole word it
* could fit on several.
*
* One case keeps the ellipsis: a single **[rtl]** line. 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. Wrapping needs `softWrap` on, which clips the right end either
* way, so more than one line needs no such exception.
*/
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
when {
!singleLine -> EventTitleOverflow(TextOverflow.Clip, softWrap = true)
rtl -> 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,
)
@@ -1,88 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Whether the week/day timeline seats each hour in its own cell, from the
* `show_hour_lines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off — the historical flat column.
*/
val LocalShowHourGrid = staticCompositionLocalOf { false }
/** Gap between two hour cells, matching the month grid's gap between day cells. */
val HOUR_CELL_GAP = 2.dp
/** Half a gap: what a cell — and a block seated in it — gives up at each edge. */
val HOUR_CELL_INSET = HOUR_CELL_GAP / 2
/**
* The half-gap a block seated in the grid gives up at one of its ends, so an
* on-the-hour event fills its cell instead of overhanging the seam into its
* neighbours'. Zero with the grid off, and zero at an end [cut] at midnight:
* that edge is squared off against the column's own, and stopping a half-gap
* short of it would leave the block floating there rather than running off.
*/
fun hourCellBlockInset(show: Boolean, cut: Boolean): Dp =
if (show && !cut) HOUR_CELL_INSET else 0.dp
/**
* Corner radius of an hour cell: the event chip's own, so the grid never rounds
* harder than the blocks it seats. The month grid's 12dp belongs to a cell many
* times the size — on an hour cell it out-rounds its own content.
*/
private val HOUR_CELL_CORNER = EVENT_CHIP_CORNER
/**
* Radius an hour cell of [cellHeight] by [cellWidth] pixels may round to. The
* hour pitch runs from a fit-the-day sliver up to [MAX_PINCH_HOUR_HEIGHT] and a
* week column is a seventh of the screen, so the radius is held to half the
* shorter side — the point past which the corners would meet and the cell turn
* into a lozenge.
*/
internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Float): Float =
minOf(maxRadius, cellHeight / 2f, cellWidth / 2f).coerceAtLeast(0f)
/**
* Seat each of the day's 24 hours in its own rounded cell when [show] is true,
* so the hour boundary reads as a seam between two surfaces rather than a line
* drawn across one. Applied to a day column's content, so the cells sit over the
* column background but beneath the event blocks — blocks stay in a continuous
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourGridCells(
show: Boolean,
hourHeightPx: Float,
color: Color,
): Modifier =
if (!show) {
this
} else {
drawBehind {
val inset = HOUR_CELL_INSET.toPx()
val cellHeight = hourHeightPx - inset * 2f
if (cellHeight <= 0f) return@drawBehind
val radius = CornerRadius(
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
)
val cellSize = Size(size.width, cellHeight)
val top = inset
for (hour in 0 until 24) {
drawRoundRect(
color = color,
topLeft = Offset(0f, hour * hourHeightPx + top),
size = cellSize,
cornerRadius = radius,
)
}
}
}
@@ -21,7 +21,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics 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
@@ -37,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. */
@@ -73,11 +64,6 @@ fun HourGutter(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f, targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
label = "hourLabelAlpha", label = "hourLabelAlpha",
) )
// Each label straddles the boundary it names, so it lines up with the seam
// the hour grid leaves there instead of hanging below it. Derived from the
// label's own line height, so it holds at any font scale.
val labelStyle = MaterialTheme.typography.labelSmall
val labelLift = with(LocalDensity.current) { labelStyle.lineHeight.toDp() } / 2
Box( Box(
modifier = modifier modifier = modifier
@@ -96,12 +82,12 @@ fun HourGutter(
if (h > 0) { if (h > 0) {
Text( Text(
text = formatHourLabel(h, use24Hour, locale), text = formatHourLabel(h, use24Hour, locale),
style = labelStyle, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = hourAlpha), .copy(alpha = hourAlpha),
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.offset(y = -labelLift), .offset(y = (-6).dp),
) )
} }
} }
@@ -0,0 +1,38 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
/**
* Whether the week/day timeline draws an hour separator line, from the
* `showHourLines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off — the historical clean look.
*/
val LocalShowHourLines = staticCompositionLocalOf { false }
/**
* Draw a faint separator line at the top of each hour (1..23) when [show] is
* true. Applied to a day column's content so each line sits over the column's
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
if (!show) {
this
} else {
drawBehind {
for (hour in 1 until 24) {
val y = hour * hourHeightPx
drawLine(
color = color,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1f,
)
}
}
}
@@ -1,328 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Thickness of the out-of-view edge bar. */
private val EDGE_BAR_THICKNESS = 3.dp
/** Band the bar sits in, and the room a revealed event keeps from the edge. */
private val EDGE_TOUCH_HEIGHT = 24.dp
/** How far from the viewport's edge a tap on empty column still hits the bar. */
private val EDGE_TAP_REACH = 16.dp
/**
* How far the bar sits in from the viewport's edges — enough to clear the
* timeline's rounded 16dp corners on the outer columns.
*/
private val EDGE_BAR_VERTICAL_INSET = 4.dp
private val EDGE_BAR_HORIZONTAL_INSET = 6.dp
/** Distance outside the viewport over which a bar fades before its event appears. */
private val EDGE_FADE_LEAD = 24.dp
/** The nearest timed blocks wholly above and below a column's viewport. */
@Immutable
data class OutOfViewEvents(val above: TimedBlock?, val below: TimedBlock?) {
companion object {
val None = OutOfViewEvents(null, null)
}
}
/** Which edge of the viewport a hidden event sits past. */
enum class ViewportEdge { Top, Bottom }
/** Where [block]'s rendered bottom lands, in minutes — short blocks keep a floor. */
internal fun renderedEndMin(block: TimedBlock): Float =
maxOf(block.endMin.toFloat(), block.startMin + MIN_EVENT_FRACTION * 60f)
/**
* The blocks of one column lying out of a viewport spanning [topMin]..[bottomMin]
* (minutes of the day), nearest first. A block leaves this set as soon as any
* part of its rendered height enters the viewport.
*/
fun outOfViewEvents(
blocks: List<TimedBlock>,
topMin: Float,
bottomMin: Float,
): OutOfViewEvents {
val above = blocks
.filter { renderedEndMin(it) <= topMin }
.maxWithOrNull(compareBy<TimedBlock>({ renderedEndMin(it) }, { it.startMin }))
val below = blocks
.filter { it.startMin >= bottomMin }
.minWithOrNull(compareBy<TimedBlock>({ it.startMin }, { -renderedEndMin(it) }))
return OutOfViewEvents(above, below)
}
/**
* The scroll offset that brings [block] into a [viewportPx]-tall viewport from
* past [edge], with [marginPx] of room so it clears the edge bar. An event above
* lands with its start at the top; one below lands whole at the bottom when it
* fits, and with its start at the top when it does not.
*/
fun revealScrollTarget(
block: TimedBlock,
edge: ViewportEdge,
hourPx: Float,
viewportPx: Int,
marginPx: Float,
maxScroll: Int,
): Int {
val startPx = block.startMin / 60f * hourPx
val endPx = renderedEndMin(block) / 60f * hourPx
val target = when (edge) {
ViewportEdge.Top -> startPx - marginPx
ViewportEdge.Bottom -> minOf(endPx + marginPx - viewportPx, startPx - marginPx)
}
return target.roundToInt().coerceIn(0, maxOf(maxScroll, 0))
}
/**
* The edge whose bar a tap at [y] (content px) lands on, if that bar is showing
* and the tap is within [reachPx] of the viewport's edge.
*/
fun tappedEdge(
events: OutOfViewEvents,
y: Float,
viewportTop: Float,
viewportPx: Int,
reachPx: Float,
): ViewportEdge? = when {
events.above != null && y - viewportTop <= reachPx -> ViewportEdge.Top
events.below != null && viewportTop + viewportPx - y <= reachPx -> ViewportEdge.Bottom
else -> null
}
/** Fade while the event is still outside; never draw its bar at the boundary. */
internal fun edgeBarAlpha(gapPx: Float, fadeLeadPx: Float): Float = when {
gapPx <= 0f -> 0f
fadeLeadPx <= 0f -> 1f
else -> (gapPx / fadeLeadPx).coerceIn(0f, 1f)
}
/**
* The out-of-view state of one day column (#188): which events hide past its
* edges, and the scroll that reveals them.
*
* The bars take no touches themselves, so event blocks under them keep their
* taps and drags; the column's own tap handler offers taps to [tap] first.
*/
@Stable
class OutOfViewEdges internal constructor(
private val scrollState: ScrollState,
private val scope: CoroutineScope,
) {
internal var blocks by mutableStateOf(emptyList<TimedBlock>())
internal var hourPx by mutableFloatStateOf(0f)
internal var fadeLeadPx by mutableFloatStateOf(0f)
internal var marginPx by mutableFloatStateOf(0f)
internal var reachPx by mutableFloatStateOf(0f)
internal var reduceMotion = false
internal var revealSpec: FiniteAnimationSpec<Float>? = null
val events: OutOfViewEvents by derivedStateOf {
val viewport = scrollState.viewportSize
if (viewport <= 0 || hourPx <= 0f) {
OutOfViewEvents.None
} else {
val top = scrollState.value.toFloat()
outOfViewEvents(
blocks = blocks,
topMin = top / hourPx * 60f,
bottomMin = (top + viewport) / hourPx * 60f,
)
}
}
/** Scrolls the event hidden past [edge] into view, if there is one. */
fun reveal(edge: ViewportEdge) {
val block = if (edge == ViewportEdge.Top) events.above else events.below
block ?: return
val target = revealScrollTarget(
block = block,
edge = edge,
hourPx = hourPx,
viewportPx = scrollState.viewportSize,
marginPx = marginPx,
maxScroll = scrollState.maxValue,
)
val spec = revealSpec
scope.launch {
if (reduceMotion || spec == null) {
scrollState.scrollTo(target)
} else {
scrollState.animateScrollTo(target, spec)
}
}
}
/** Reveals the hidden event if a tap at [y] (content px) lands on a bar. */
fun tap(y: Float): Boolean {
val edge = tappedEdge(
events = events,
y = y,
viewportTop = scrollState.value.toFloat(),
viewportPx = scrollState.viewportSize,
reachPx = reachPx,
) ?: return false
reveal(edge)
return true
}
}
@Composable
fun rememberOutOfViewEdges(
blocks: List<TimedBlock>,
scrollState: ScrollState,
hourPx: Float,
): OutOfViewEdges {
val scope = rememberCoroutineScope()
val edges = remember(scrollState, scope) { OutOfViewEdges(scrollState, scope) }
val density = LocalDensity.current
val reduceMotion = rememberReduceMotion()
val revealSpec = rememberCalendarPageSpec()
SideEffect {
edges.blocks = blocks
edges.hourPx = hourPx
edges.fadeLeadPx = with(density) { EDGE_FADE_LEAD.toPx() }
edges.marginPx = with(density) { EDGE_TOUCH_HEIGHT.roundToPx().toFloat() }
edges.reachPx = with(density) { EDGE_TAP_REACH.toPx() }
edges.reduceMotion = reduceMotion
edges.revealSpec = revealSpec
}
return edges
}
/**
* Thin bars at the top and bottom of a day column's visible viewport, tinted in
* the colour of the nearest timed event hidden past that edge (#188).
*
* Lives inside the column's scrolling content and follows the viewport by
* offsetting against [scrollState] at placement rather than recomposing every
* frame. Opacity follows the gap to the hidden event and reaches zero before
* the event enters view. Taps reach it through [OutOfViewEdges.tap]; screen
* readers get a button.
*/
@Composable
fun BoxScope.OutOfViewEdgeBars(
edges: OutOfViewEdges,
scrollState: ScrollState,
dark: Boolean,
) {
val touchPx = with(LocalDensity.current) { EDGE_TOUCH_HEIGHT.roundToPx() }
val events = edges.events
EdgeBar(
block = events.above,
edge = ViewportEdge.Top,
dark = dark,
onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value) },
)
EdgeBar(
block = events.below,
edge = ViewportEdge.Bottom,
dark = dark,
onReveal = edges::reveal,
scrollState = scrollState,
hourPx = edges.hourPx,
fadeLeadPx = edges.fadeLeadPx,
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(0, scrollState.value + scrollState.viewportSize - touchPx) },
)
}
@Composable
private fun EdgeBar(
block: TimedBlock?,
edge: ViewportEdge,
dark: Boolean,
onReveal: (ViewportEdge) -> Unit,
scrollState: ScrollState,
hourPx: Float,
fadeLeadPx: Float,
modifier: Modifier = Modifier,
) {
block ?: return
val color = eventFill(block.event.color, dark, LocalSoftenColors.current)
val label = stringResource(
if (edge == ViewportEdge.Top) R.string.timeline_earlier_events else R.string.timeline_later_events,
)
Box(
modifier = modifier
.fillMaxWidth()
.height(EDGE_TOUCH_HEIGHT)
.graphicsLayer {
val gapPx = when (edge) {
ViewportEdge.Top -> scrollState.value - renderedEndMin(block) / 60f * hourPx
ViewportEdge.Bottom -> block.startMin / 60f * hourPx -
(scrollState.value + scrollState.viewportSize)
}
alpha = edgeBarAlpha(gapPx, fadeLeadPx)
}
.semantics {
role = Role.Button
contentDescription = label
onClick {
onReveal(edge)
true
}
},
contentAlignment = if (edge == ViewportEdge.Top) Alignment.TopCenter else Alignment.BottomCenter,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = EDGE_BAR_HORIZONTAL_INSET,
vertical = EDGE_BAR_VERTICAL_INSET,
)
.height(EDGE_BAR_THICKNESS)
.background(color, CircleShape),
)
}
}
@@ -1,53 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
/**
* One shared state per pager page — a week, a day, a month — so a page and the
* screen's anchor read the same query, and the pages either side stay loaded
* across a swipe.
*
* Every entry shares on its own job, cancelled when [keep] lets it go: a
* `stateIn` on the ViewModel's scope would stay running, holding its last state,
* until the ViewModel itself was cleared. Main thread only.
*
* @param keep whether a cached key is still worth holding once [get] asks for another.
*/
class PageStateCache<K, S>(
private val scope: CoroutineScope,
private val initial: S,
private val keep: (cached: K, requested: K) -> Boolean,
private val load: (K) -> Flow<S>,
) {
private class Entry<S>(val state: StateFlow<S>, val job: Job)
private val entries = HashMap<K, Entry<S>>()
/** The number of entries held, for tests. */
internal val size: Int get() = entries.size
fun get(key: K): StateFlow<S> {
val iterator = entries.iterator()
while (iterator.hasNext()) {
val (cached, entry) = iterator.next()
if (cached != key && !keep(cached, key)) {
entry.job.cancel()
iterator.remove()
}
}
return entries.getOrPut(key) {
val job = SupervisorJob(scope.coroutineContext[Job])
Entry(
state = load(key).stateIn(scope + job, SharingStarted.WhileSubscribed(5_000L), initial),
job = job,
)
}.state
}
}
@@ -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
/** /**
@@ -12,36 +8,9 @@ import kotlin.time.Instant
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when * drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
* the "dim completed events" setting is on, or `null` when it is off (nothing * the "dim completed events" setting is on, or `null` when it is off (nothing
* dims). Provided per grid screen so only the event chips that read it recompose * dims). Provided per grid screen so only the event chips that read it recompose
* as the minute ticks, mirroring [LocalShowHourGrid] / [LocalUse24HourFormat]. * as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
*/ */
val LocalDimCutoff = compositionLocalOf<Instant?> { null } 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)
}
}
@@ -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 ->
@@ -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
} }
/** /**
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -25,9 +24,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
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
@@ -36,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
@@ -46,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
@@ -72,181 +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>,
/**
* Lines the block the finger picked up drew its title over, zero for one too
* short to have drawn it at all. The floating copy is that block's size, so
* it has to spend its height the same way or the text re-wraps under the
* finger and snaps back on drop (#267).
*/
val titleLines: Int,
/**
* 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
}
/**
* Where one dragged piece's top edge sits below its column's top, and how tall
* it is drawn, at [hourPx] to the hour. [insetPx] is the half-gap a block seated
* in the hour grid gives up at each end, zero with the grid off — taken off the
* copy exactly as the grid takes it off the block, or the copy is a gap taller
* than the block it lifted off and spends the difference on a time label that
* block had no room for (#267). An end cut at midnight keeps none of it, as the
* block's own squared-off edge does.
*/
internal fun dragPieceBounds(
slice: DragSlice,
hourPx: Float,
insetPx: Float,
): DragPieceBounds {
val top = if (slice.continuesBefore) 0f else insetPx
val bottom = if (slice.continuesAfter) 0f else insetPx
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
return DragPieceBounds(
top = slice.startMin / 60f * hourPx + top,
height = (full - top - bottom).coerceAtLeast(0f),
)
}
/** A piece's placement within its column — see [dragPieceBounds]. */
internal data class DragPieceBounds(val top: Float, val height: Float)
/** 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,13 +99,6 @@ class TimelineGeometry {
var stepPx: Float = 0f var stepPx: Float = 0f
var days: List<LocalDate> = emptyList() var days: List<LocalDate> = emptyList()
/**
* The half-gap a block gives up at each uncut end when the hour grid seats
* it in a cell, zero with the grid off. The floating copy gives up the same,
* so it is the size of the block it lifted off (#267).
*/
var blockInsetPx: Float = 0f
/** /**
* Whether the columns are laid out right-to-left. Pointer coordinates are * Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored but the grid is, so the mapping has to flip with it. * never mirrored but the grid is, so the mapping has to flip with it.
@@ -284,8 +107,8 @@ class TimelineGeometry {
} }
/** /**
* Hoisted drag state for one timeline (#68). It lives above the pager, so a * Hoisted drag state for one timeline (#68). It lives above the per-page
* page change mid-drag can't strand a ghost, and the * `AnimatedContent`, so a page change mid-drag can't strand a ghost, and the
* block it renders is drawn in an overlay, clear of the day column's clip. * block it renders is drawn in an overlay, clear of the day column's clip.
*/ */
@Stable @Stable
@@ -311,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)
@@ -332,47 +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
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 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,
titleLines: Int,
pointerInRoot: Offset,
blockInRoot: Offset,
) {
source = block source = block
this.clipOffsetMin = clipOffsetMin
this.titleLines = titleLines
settling = null settling = null
isDragging = true isDragging = true
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
@@ -390,8 +182,6 @@ class TimelineDragController {
fun cancel() { fun cancel() {
source = null source = null
clipOffsetMin = 0
titleLines = 0
isDragging = false isDragging = false
liftedInstanceId = null liftedInstanceId = null
originSlot = null originSlot = null
@@ -402,41 +192,32 @@ 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 ||
( block.event.title == landed.event.title
landed.event.title.isNotBlank() &&
block.event.title == landed.event.title
)
) )
} }
} }
@@ -449,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)
} }
/** /**
@@ -496,46 +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:00–02: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, topLeftInRoot = Offset(
titleLines = titleLines, x = origin.x + column * columnPx,
// Bounded to the columns this timeline actually shows: a day it y = origin.y + startMin / 60f * hourPx,
// doesn't has no piece to draw. The write is unaffected. ),
pieces = slices sizePx = IntSize(
.map { slice -> (columnPx - geometry.columnGapPx).roundToInt(),
val day = dayIndex + slice.dayOffset height.roundToInt(),
val col = if (geometry.isRtl) days.lastIndex - day else day ),
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece(
topLeftInRoot = Offset(
x = origin.x + col * columnPx,
y = origin.y + bounds.top,
),
sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(),
),
continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter,
)
},
edgeOnly = onGrid.isEmpty(),
) )
} }
@@ -577,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
@@ -650,11 +379,12 @@ const val SETTLE_FADE_MILLIS: Int = 250
@Composable @Composable
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) { fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) } var origin by remember { mutableStateOf(Offset.Zero) }
val density = LocalDensity.current
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
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
@@ -667,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 delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
// land and there is nothing to wait for.
if (abandoned == controller.settledAbandonedMark) {
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())
@@ -701,180 +426,57 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
animationSpec = tween(SETTLE_FADE_MILLIS), animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover", label = "drag-handover",
) )
val paint = eventPaint(drag.event, dark) 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:00–02:00" and not "22:00–24: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:00–02:00" and not "22:00–24: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 range = formatMinuteOfDay(startMin, use24Hour, locale) to val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
formatMinuteOfDay(endMin, use24Hour, locale) formatMinuteOfDay(endMin, use24Hour, locale)
// The timeline's own bounds. The copy is drawn over the whole calendar
// so no column clip or rounded corner cuts it, but it still belongs to
// the timeline: a piece a whole day tall reaches far past both ends of
// the viewport, and unclipped it paints over the headers above (#267).
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot()
// How much of each piece the viewport actually shows. A multi-day event
// has a piece per day, and the widest one is a full 24 hours — taller
// than the screen, its top at a midnight scrolled out of sight. Picking
// the range's piece by raw height put it there, off the top of the
// timeline; picking by *visible* height puts it where it can be read.
val shown = drag.pieces.map { piece ->
if (port == null) {
piece.sizePx.height.toFloat()
} else {
val top = maxOf(piece.topLeftInRoot.y, port.top)
val bottom = minOf(piece.topLeftInRoot.y + piece.sizePx.height, port.bottom)
bottom - top
}
}
val labelled = shown.indices.maxByOrNull { shown[it] }
Box( Box(
modifier = if (port == null) { modifier = Modifier
Modifier // Absolute: these are root coordinates, and the direction-aware
} else { // offset would mirror them across the screen in an RTL layout.
Modifier .absoluteOffset {
.absoluteOffset { IntOffset(
IntOffset( (drag.topLeftInRoot.x - origin.x).roundToInt(),
(port.left - origin.x).roundToInt(), (drag.topLeftInRoot.y - origin.y).roundToInt(),
(port.top - origin.y).roundToInt(),
)
}
.size(
width = with(density) { port.width.toDp() },
height = with(density) { port.height.toDp() },
) )
.clipToBounds() }
}, .size(
width = with(density) { drag.sizePx.width.toDp() },
height = with(density) { drag.sizePx.height.toDp() },
)
.padding(horizontal = 1.dp)
.graphicsLayer {
scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift
alpha = copyAlpha
shape = RoundedCornerShape(4.dp)
clip = false
}
.background(fill, RoundedCornerShape(4.dp))
.padding(horizontal = 4.dp, vertical = 2.dp),
) { ) {
val pieceOrigin = port?.topLeft ?: origin Column {
drag.pieces.forEachIndexed { index, piece -> Text(
DragCopy( text = title,
topLeftInRoot = piece.topLeftInRoot, style = MaterialTheme.typography.labelMedium,
overlayOrigin = pieceOrigin, maxLines = 1,
sizePx = piece.sizePx, overflow = TextOverflow.Ellipsis,
paint = paint, color = eventInk(fill, alpha = 0.85f),
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
titleLines = drag.titleLines,
// Once for the whole event: repeated, it would name it per day.
range = range.takeIf { index == labelled },
) )
}
}
}
}
/** One piece of a dragged block, floating over the calendar. */
@Composable
private fun DragCopy(
topLeftInRoot: Offset,
overlayOrigin: Offset,
sizePx: IntSize,
paint: EventPaint,
shape: RoundedCornerShape,
cuts: ChipCuts,
lift: Float,
alpha: Float,
title: String,
titleLines: Int,
range: Pair<String, String>?,
) {
val density = LocalDensity.current
val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() }
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
// The block's size, spent the block's way — text sits at the top as it does
// on the block, so the copy hands back to the grid without shifting (#267),
// and it squeezes its inset on the same terms so a short block's title does
// not vanish the moment it is lifted (#289).
// The title is served in full first and the range lives off what is left:
// the hour gutter down the side still says where the copy sits, so the
// range is the half that can afford to go.
val metrics = rememberBlockTextMetrics(height)
val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
// Re-measured at the copy's own width rather than spent on the source's
// count: a block sharing its column with another is a lane wide where the
// copy is a whole column, so the source's second line is one the copy never
// draws — and reserving it took the range away with it (#267).
val lines = if (allowed <= 0) {
0
} else {
blockTextLines(
text = title,
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = allowed,
)
}
val left = metrics.available - metrics.titleHeight(lines)
val label = range?.let { rememberBlockTimeRange(it.first, it.second, textWidth) }
val showTime = label != null && left >= metrics.timeLine
val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
} else {
1
}
Box(
modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout.
.absoluteOffset {
IntOffset(
(topLeftInRoot.x - overlayOrigin.x).roundToInt(),
(topLeftInRoot.y - overlayOrigin.y).roundToInt(),
)
}
// Required: the parent is the viewport's size, and a plain `size`
// would let it cap a piece a whole day tall at one screen — drawn
// from a midnight scrolled off the top, that ended the column a
// scroll offset short of its own bottom (#267). The parent clips it.
.requiredSize(width = width, height = height)
.padding(horizontal = BLOCK_OUTER_INSET)
.graphicsLayer {
// Anchored at the top edge, not the middle: scaled about the
// centre, the 2% lift raises the top by 1% of the height — a few
// pixels on an hour, but a steady upward creep of the text as a
// dragged multi-day piece grows, and a shear against the
// timeline's clip. It snapped back when the lift animated out
// on drop (#267).
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift
this.alpha = alpha
this.shape = shape
clip = false
}
.eventSurface(paint, shape, cuts)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
) {
Column {
if (lines > 0) {
BlockTitle(
title = title,
maxLines = lines,
color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
)
}
if (showTime) {
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
Text( Text(
text = label, text = label,
// As the block it lifted off sets its own time (#219). style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelSmall.asEventTime(), maxLines = 1,
maxLines = timeMaxLines, overflow = TextOverflow.Ellipsis,
overflow = overflow.overflow, color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
softWrap = overflow.softWrap,
color = paint.secondaryInk,
) )
} }
} }
@@ -1,58 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* The hours the week and day timelines fit to the screen when they open (#322).
*
* Nothing outside the range is hidden: [TimelineScale.FitHours] only sizes the
* hour so that [startHour]..[endHour] fills the viewport, and the timeline opens
* scrolled to [startHour]. The hours are kept when [enabled] is switched off, so
* switching it back on restores the chosen range.
*/
data class TimelineHours(
val enabled: Boolean = false,
val startHour: Int = DEFAULT_START_HOUR,
val endHour: Int = DEFAULT_END_HOUR,
) {
val span: Int get() = endHour - startHour
companion object {
const val DEFAULT_START_HOUR = 7
const val DEFAULT_END_HOUR = 22
/** Shortest range that can be chosen, in hours. */
const val MIN_SPAN = 2
/**
* A range with [startHour] and [endHour] held to the day and at least
* [MIN_SPAN] apart; the start gives way when the two collide.
*/
fun of(enabled: Boolean, startHour: Int, endHour: Int): TimelineHours {
val end = endHour.coerceIn(MIN_SPAN, 24)
val start = startHour.coerceIn(0, end - MIN_SPAN)
return TimelineHours(enabled, start, end)
}
/** Start hours that still leave [MIN_SPAN] before [endHour]. */
fun startOptions(endHour: Int): List<Int> = (0..endHour - MIN_SPAN).toList()
/** End hours at least [MIN_SPAN] after [startHour]; 24 is midnight. */
fun endOptions(startHour: Int): List<Int> = (startHour + MIN_SPAN..24).toList()
}
}
/**
* The scroll offset that puts [startHour] at the top of a timeline laid out
* with [maxValue] scroll range over a [viewportPx] viewport, or null while that
* layout is not yet at [hourPx] per hour — the scale changes a frame before the
* content grows to match it, and scrolling against the old layout would land on
* the wrong hour.
*/
internal fun startHourScroll(maxValue: Int, viewportPx: Int, startHour: Int, hourPx: Float): Int? {
if (viewportPx <= 0 || maxValue < 0 || maxValue == Int.MAX_VALUE) return null
val laidOutHourPx = (maxValue + viewportPx) / 24f
if (abs(laidOutHourPx - hourPx) > 1f) return null
return (startHour * laidOutHourPx).roundToInt().coerceIn(0, maxValue)
}
@@ -38,12 +38,6 @@ sealed interface TimelineScale {
/** A height the user pinched to. Build it through [custom], which clamps. */ /** A height the user pinched to. Build it through [custom], which clamps. */
data class Custom(val hourHeight: Dp) : TimelineScale data class Custom(val hourHeight: Dp) : TimelineScale
/**
* [startHour]..[endHour] fills the viewport (#322). Applied when a timeline
* opens with [TimelineHours] on, never picked from the preset list.
*/
data class FitHours(val startHour: Int, val endHour: Int) : TimelineScale
companion object { companion object {
/** The named steps the settings picker offers, coarse to roomy. */ /** The named steps the settings picker offers, coarse to roomy. */
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable) val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
@@ -80,9 +74,6 @@ fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
is TimelineScale.Custom -> this.hourHeight is TimelineScale.Custom -> this.hourHeight
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT) .coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
.coerceAtLeast(fillHourHeight(viewportHeight)) .coerceAtLeast(fillHourHeight(viewportHeight))
is TimelineScale.FitHours -> (viewportHeight / (endHour - startHour).coerceIn(1, 24).toFloat())
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
.coerceAtLeast(fillHourHeight(viewportHeight))
} }
/** /**
@@ -105,6 +96,17 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
*/ */
const val MIN_EVENT_FRACTION = 26f / 60f const val MIN_EVENT_FRACTION = 26f / 60f
/**
* Narrowest an event block may be and still wrap its title over several lines.
*
* Wrapping is driven by the block's height, so a tall block on a lane-split
* column would otherwise stack two or three characters per line — "Da/ily",
* "Fa/rmer/s…" — which reads worse than one ellipsised line. A full week column
* clears this on any phone; a split one never does, while the day view's much
* wider columns keep wrapping even several lanes deep.
*/
val MIN_TITLE_WRAP_WIDTH = 36.dp
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */ /** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp val FIT_DAY_MIN = 24.dp
@@ -143,7 +145,6 @@ val TimelineScale.labelRes: Int
TimelineScale.Regular -> R.string.timeline_scale_regular TimelineScale.Regular -> R.string.timeline_scale_regular
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
is TimelineScale.Custom -> R.string.timeline_scale_custom is TimelineScale.Custom -> R.string.timeline_scale_custom
is TimelineScale.FitHours -> R.string.settings_visible_hours
} }
@get:StringRes @get:StringRes
@@ -154,15 +155,11 @@ val TimelineScale.descriptionRes: Int
TimelineScale.Regular -> R.string.timeline_scale_regular_summary TimelineScale.Regular -> R.string.timeline_scale_regular_summary
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
is TimelineScale.FitHours -> R.string.settings_visible_hours_summary
} }
/** Marks a stored custom height; the rest of the value is its dp. */ /** Marks a stored custom height; the rest of the value is its dp. */
private const val CUSTOM_PREFIX = "custom:" private const val CUSTOM_PREFIX = "custom:"
/** Marks a stored hour range, as `start-end`. */
private const val HOURS_PREFIX = "hours:"
/** /**
* Stored names for the presets. Spelled out rather than taken from `toString()` * Stored names for the presets. Spelled out rather than taken from `toString()`
* so R8 can't rename them out from under an existing install — and they match * so R8 can't rename them out from under an existing install — and they match
@@ -185,7 +182,6 @@ private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
*/ */
fun TimelineScale.storageValue(): String = when (this) { fun TimelineScale.storageValue(): String = when (this) {
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
is TimelineScale.FitHours -> "$HOURS_PREFIX$startHour-$endHour"
TimelineScale.FitDay, TimelineScale.FitDay,
TimelineScale.Compact, TimelineScale.Compact,
TimelineScale.Regular, TimelineScale.Regular,
@@ -203,11 +199,5 @@ fun parseTimelineScale(stored: String?): TimelineScale = when {
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull() stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
?.let { TimelineScale.custom(it.dp) } ?.let { TimelineScale.custom(it.dp) }
?: TimelineScale.Regular ?: TimelineScale.Regular
stored.startsWith(HOURS_PREFIX) -> stored.removePrefix(HOURS_PREFIX).split('-')
.mapNotNull { it.toIntOrNull() }
.takeIf { it.size == 2 }
?.let { (start, end) -> TimelineHours.of(enabled = true, start, end) }
?.let { TimelineScale.FitHours(it.startHour, it.endHour) }
?: TimelineScale.Regular
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
} }
@@ -1,111 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.roundToInt
/**
* Where 00:00 sat in the last timeline shown, measured from the top of its
* view's content, so the next timeline view opens on the same hours (#184): an
* event in both keeps its place and only grows sideways.
*/
class TimelineScrollMemory {
var midnightY: Float? = null
}
val LocalTimelineScroll = staticCompositionLocalOf<TimelineScrollMemory?> { null }
/** A timeline's scroll, and where it opens before it can scroll there. */
@Stable
class TimelineScroll(val state: ScrollState, noonPending: Boolean, private val openingHour: Int?) {
/** Whether it is still to be put on noon, which it only can be once laid out. */
var noonPending by mutableStateOf(noonPending)
internal set
/** The top of what it shows, or will once open, over [content] pixels in a [viewport] pixels high. */
fun top(viewport: Float, content: Float): Float =
if (noonPending) {
(openingHour?.let { it * content / 24f } ?: (content - viewport) / 2f)
.coerceIn(0f, (content - viewport).coerceAtLeast(0f))
} else state.value.toFloat()
}
val LocalOpeningTimeline = staticCompositionLocalOf<TimelineScroll?> { null }
/**
* The scroll state of a timeline whose scrolling area starts [timelineTop] below
* its view's content top. Opens where the last timeline left off, or on noon the
* first time.
*/
@Composable
fun rememberTimelineScrollState(timelineTop: Dp): TimelineScroll {
val memory = LocalTimelineScroll.current
val density = LocalDensity.current
val zoom = LocalTimelineZoom.current
val topPx = with(density) { timelineTop.toPx() }
val remembered = memory?.midnightY
val scrollState = rememberScrollState(
initial = remembered?.let { (topPx - it).roundToInt().coerceAtLeast(0) } ?: 0,
)
val scroll = remember(scrollState) {
TimelineScroll(
scrollState,
noonPending = remembered == null,
openingHour = zoom.hours.takeIf { it.enabled }?.startHour,
)
}
val currentTop by rememberUpdatedState(topPx)
var firstOpen by remember(scrollState) { mutableStateOf(true) }
LaunchedEffect(scrollState, zoom, zoom.hours) {
if (firstOpen && remembered != null) {
firstOpen = false
return@LaunchedEffect
}
firstOpen = false
val startHour = zoom.open()
if (startHour == null) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
scrollState.scrollTo(scrollState.maxValue / 2)
} else {
// Fitted to the scroll viewport itself: the layout resolves scales
// against the viewport plus the all-day strip, which would push the
// end hour below the screen.
val viewport = snapshotFlow { scrollState.viewportSize }.first { it > 0 }
val hourHeight = zoom.scale.hourHeight(with(density) { viewport.toDp() })
zoom.holdFit(hourHeight)
val hourPx = with(density) { hourHeight.toPx() }
val target = withTimeoutOrNull(FIT_LAYOUT_TIMEOUT_MILLIS) {
snapshotFlow {
startHourScroll(scrollState.maxValue, scrollState.viewportSize, startHour, hourPx)
}.filterNotNull().first()
} ?: (startHour * (scrollState.maxValue + scrollState.viewportSize) / 24)
.coerceIn(0, scrollState.maxValue.coerceAtLeast(0))
scrollState.scrollTo(target)
}
scroll.noonPending = false
}
if (memory != null) {
LaunchedEffect(scrollState, memory) {
snapshotFlow { currentTop - scrollState.value }.collect { memory.midnightY = it }
}
}
return scroll
}
/** How long an opening timeline waits to be laid out at its fitted hours before scrolling anyway. */
private const val FIT_LAYOUT_TIMEOUT_MILLIS = 500L
@@ -19,6 +19,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -49,49 +50,13 @@ class TimelineZoom(
var isPinching: Boolean by mutableStateOf(false) var isPinching: Boolean by mutableStateOf(false)
private set private set
/** The visible-hours preference (#322); a timeline re-fits when it changes. */
var hours: TimelineHours by mutableStateOf(TimelineHours())
private set
private var stored: TimelineScale = initial
/** /**
* Take a value that came from the preference. Ignored mid-pinch: the stored * Take a value that came from the preference. Ignored mid-pinch: the stored
* value is a frame or two behind the fingers there, and letting it land would * value is a frame or two behind the fingers there, and letting it land would
* snap the timeline back while the user is still pinching. With visible * snap the timeline back while the user is still pinching.
* hours on it is only remembered, so a late-arriving preference can't undo
* the fit a timeline just opened with.
*/ */
fun adopt(stored: TimelineScale) { fun adopt(stored: TimelineScale) {
this.stored = stored if (!isPinching) scale = stored
if (!isPinching && !hours.enabled) scale = stored
}
/** Take the visible-hours preference; switching it off hands back the stored scale. */
fun adoptHours(hours: TimelineHours) {
val wasEnabled = this.hours.enabled
this.hours = hours
if (wasEnabled && !hours.enabled && !isPinching) scale = stored
}
/**
* A timeline is opening. With visible hours on, the range replaces whatever
* scale is in force — a stored preset and a pinch alike — and its start hour
* is returned for the timeline to scroll to; otherwise null.
*/
fun open(): Int? {
val hours = hours
if (!hours.enabled) return null
if (!isPinching) scale = TimelineScale.FitHours(hours.startHour, hours.endHour)
return hours.startHour
}
/**
* Holds the height [open] fitted, so a viewport that changes afterwards (the
* all-day strip following a swipe) doesn't rescale the hours under the scroll.
*/
fun holdFit(hourHeight: Dp) {
if (!isPinching && scale is TimelineScale.FitHours) scale = TimelineScale.Custom(hourHeight)
} }
fun beginPinch() { fun beginPinch() {
@@ -102,10 +67,9 @@ class TimelineZoom(
scale = TimelineScale.custom(hourHeight) scale = TimelineScale.custom(hourHeight)
} }
/** Settles the pinch. With visible hours on it only lasts until the next [open]. */
fun endPinch() { fun endPinch() {
isPinching = false isPinching = false
if (!hours.enabled) persist(scale) persist(scale)
} }
} }
@@ -119,18 +83,16 @@ private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom } val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
/** /**
* The app-wide [TimelineZoom], seeded from [stored] and [hours] and writing * The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
* settled pinches back through [onPersist]. * back through [onPersist].
*/ */
@Composable @Composable
fun rememberTimelineZoom( fun rememberTimelineZoom(
stored: TimelineScale, stored: TimelineScale,
hours: TimelineHours,
onPersist: (TimelineScale) -> Unit, onPersist: (TimelineScale) -> Unit,
): TimelineZoom { ): TimelineZoom {
val persist by rememberUpdatedState(onPersist) val persist by rememberUpdatedState(onPersist)
val zoom = remember { TimelineZoom(stored) { persist(it) } } val zoom = remember { TimelineZoom(stored) { persist(it) } }
LaunchedEffect(hours) { zoom.adoptHours(hours) }
// Picking a preset in Settings has to reach the timelines, and so does the // Picking a preset in Settings has to reach the timelines, and so does the
// stored value arriving after the first frame. // stored value arriving after the first frame.
LaunchedEffect(stored) { zoom.adopt(stored) } LaunchedEffect(stored) { zoom.adopt(stored) }
@@ -235,26 +197,18 @@ fun rememberTimelinePinchZoom(
* names, jumping the whole column as a pinch drifts across each half pixel. * names, jumping the whole column as a pinch drifts across each half pixel.
* Pinning the hour to whole pixels keeps every part of the timeline on one grid. * Pinning the hour to whole pixels keeps every part of the timeline on one grid.
* *
* The ceiling is pulled onto that grid too, downwards, so it stays a height the * The bounds themselves are pulled onto that grid too, each in the direction
* pinch can actually land on. * that keeps its own promise — up for the fill floor, so no dead space opens
* * under midnight, down for the ceiling. A fractional bound would be a height the
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole * pinch can be held against but never actually land on, and the difference feeds
* day exactly fills the viewport at, and it is the same value * the focal anchor a scroll correction on every frame the fingers sit still.
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
* hour taller than its own viewport, so a pinched-all-the-way-out day still
* scrolls a hair and bounces off Android's overscroll stretch, while the
* identical FitDay preset sits still. Being the clamp result rather than a bound
* the gesture is merely held against, it is a height the pinch does land on: the
* next frame reads it back unchanged and the focal anchor is handed nothing to
* correct.
*/ */
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float = internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
// Filling the viewport wins over the ceiling: on a screen tall enough for // Filling the viewport wins over the ceiling: on a screen tall enough for
// the two to disagree, dead space is the worse of the two failures. // the two to disagree, dead space is the worse of the two failures.
target.roundToInt().toFloat() target.roundToInt().toFloat()
.coerceAtMost(floor(maxPx)) .coerceAtMost(floor(maxPx))
.coerceAtLeast(fillPx) .coerceAtLeast(ceil(fillPx))
/** /**
* The scroll offset that keeps the moment under [centroidY] under it after the * The scroll offset that keeps the moment under [centroidY] under it after the
@@ -1,82 +1,26 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.border import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.Box import androidx.compose.material.icons.filled.Today
import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
private val GlyphShape = RoundedCornerShape(7.dp)
private val GlyphBorder = 1.5.dp
/**
* Height of the day number. Expressed in dp and converted to sp at draw time so
* it does not follow the font scale: this is an icon glyph sharing the bar with
* stroke icons, and a two-digit day that grew with the text would push the bar
* past the width #165 measured it to. The button's content description carries
* the meaning for screen readers, so nothing is lost by holding it still.
*/
private val GlyphTextHeight = 12.dp
/** /**
* The top-bar "jump to today" icon button, shared by every calendar view's app * The top-bar "jump to today" icon button, shared by every calendar view's app
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the * bar (#60). Shown in place of the fade-in FAB pill when the user moves the
* today control into the toolbar; renders nothing when [show] is false, so it * today control into the toolbar; renders nothing when [show] is false, so it
* drops straight into an app bar's `actions` slot without a wrapping condition. * drops straight into an app bar's `actions` slot without a wrapping condition.
*
* The glyph is the current day number in an outlined box rather than a generic
* calendar icon (#220), so the bar also says what day it is. Outlined rather
* than the month grid's filled circle, which reads as "this cell is today" and
* would sit heavier here than the stroke icons beside it.
*/ */
@Composable @Composable
fun TodayAction(show: Boolean, onToday: () -> Unit) { fun TodayAction(show: Boolean, onToday: () -> Unit) {
if (!show) return if (!show) return
val now = rememberCurrentMinute() IconButton(onClick = onToday) {
// Only the day number is read, so the bar recomposes at midnight rather Icon(
// than on every minute tick. imageVector = Icons.Default.Today,
val day by remember { contentDescription = stringResource(R.string.today_jump_action),
derivedStateOf { now.value.toLocalDateTime(TimeZone.currentSystemDefault()).date.day } )
}
val description = stringResource(R.string.today_jump_action)
IconButton(
onClick = onToday,
modifier = Modifier
.semantics { contentDescription = description },
) {
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
Box(
modifier = Modifier
.size(AppBarSpacing.IconSize)
.border(GlyphBorder, LocalContentColor.current, GlyphShape),
contentAlignment = Alignment.Center,
) {
Text(
text = day.toString(),
fontSize = textSize,
lineHeight = textSize,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = LocalContentColor.current,
)
}
} }
} }
@@ -1,49 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import kotlinx.datetime.LocalDate
/**
* The date every calendar view is looking at (#184), so switching views keeps
* the same days on screen and what is shown in both can travel between them.
*
* A view reads it once, as it comes on screen, and writes it back only when the
* user moves: a view settling where it opened must not nudge it, or switching
* back and forth would drift.
*/
@Stable
class ViewFocus(initial: LocalDate) {
var date by mutableStateOf(initial)
/** Explicit navigation, including a request for the view already on screen. */
var navigationVersion by mutableStateOf(0)
private set
fun navigateTo(target: LocalDate) {
date = target
navigationVersion++
}
}
val ViewFocusSaver: Saver<ViewFocus, String> = Saver(
save = { it.date.toString() },
restore = { ViewFocus(LocalDate.parse(it)) },
)
val LocalViewFocus = staticCompositionLocalOf<ViewFocus?> { null }
/**
* Runs [enter] with the focused date once, as the view comes on screen, before
* it reads its own position: its first frame is then already on that date.
*/
@Composable
fun <T : Any> EnterOnFocus(key: T, enter: (LocalDate) -> Unit) {
val focus = LocalViewFocus.current
remember(key) { focus?.date?.also(enter) }
}
@@ -1,177 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.LocalDate
/**
* What the views share when switching between them (#184): an event visible in
* both grows from where it sat into where it lands, a date label travels to its
* new seat, and everything else fades through.
*
* An element is tagged with the day it is drawn *from*, not just its instance: a
* multi-day event is drawn once per day, per week row or per timeline column,
* and only one element per key may take part. The segment that starts on the
* same day in both views is the one that travels; the rest fade where they are.
*/
sealed interface ViewMorphKey {
data class Event(val eventId: Long, val beginMillis: Long, val day: LocalDate) : ViewMorphKey
data class Day(val date: LocalDate) : ViewMorphKey
companion object {
/** [event]'s occurrence as drawn from [day]. */
fun event(event: EventInstance, day: LocalDate): Event =
Event(event.eventId, event.start.toEpochMilliseconds(), day)
}
}
/** The view switch's scopes and specs, captured where they exist. Null under reduced motion. */
@OptIn(ExperimentalSharedTransitionApi::class)
class ViewMorphScope(
val shared: SharedTransitionScope,
val visibility: AnimatedVisibilityScope,
val boundsTransform: BoundsTransform,
val fadeSpec: FiniteAnimationSpec<Float>,
val clip: BelowTopBarClip,
val stayingPut: StayingPut,
/** Read while matching, so a tag can sit out without leaving the modifier chain. */
val enabled: () -> Boolean = { true },
) {
/** This scope with its tags switched off while [enabled] is false. */
fun gated(enabled: () -> Boolean): ViewMorphScope =
ViewMorphScope(shared, visibility, boundsTransform, fadeSpec, clip, stayingPut, enabled)
internal val config = object : SharedTransitionScope.SharedContentConfig {
override val SharedTransitionScope.SharedContentState.isEnabled: Boolean
get() = enabled()
}
}
/**
* The events a view has but keeps out of the switch, being scrolled out of
* sight: the other view leaves them untagged too, or its half would travel to
* nowhere and snap into place at the end.
*/
class StayingPut {
val keys = mutableStateSetOf<ViewMorphKey.Event>()
}
/** Marks [key] as staying put while [stays], for as long as it is composed. */
@Composable
fun StayPut(key: ViewMorphKey.Event, stays: Boolean) {
val stayingPut = LocalViewMorph.current?.stayingPut ?: return
if (!stays) return
DisposableEffect(stayingPut, key) {
stayingPut.keys += key
onDispose { stayingPut.keys -= key }
}
}
/**
* Keeps what travels below the top bar, which every view has at the same height:
* the overlay draws above the whole screen, so an event scrolled under the bar
* would otherwise cross it. [top] is the bar's bottom edge in the shared layout.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
class BelowTopBarClip : SharedTransitionScope.OverlayClip {
var top = 0f
override fun getClipPath(
sharedContentState: SharedTransitionScope.SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density,
): Path = Path().apply { addRect(Rect(-FAR, top, FAR, FAR)) }
private companion object {
const val FAR = 1e6f
}
}
val LocalViewMorph = compositionLocalOf<ViewMorphScope?> { null }
/**
* Whether a pager page may carry the morph tags: only the settled one. Its
* neighbours are composed ahead and repeat some of its dates, which would both
* claim its keys and fly in from off screen.
*/
fun morphPageActive(page: Int, settledPage: Int): Boolean = page == settledPage
/** Provides the view morph to [content] only while [active] — see [morphPageActive]. */
@Composable
fun ViewMorphPage(active: Boolean, content: @Composable () -> Unit) {
val morph = LocalViewMorph.current
CompositionLocalProvider(LocalViewMorph provides morph.takeIf { active }, content = content)
}
/**
* Tags an event chip, bar, block or row. Its bounds are shared and the two faces
* cross-fade inside them; remeasured rather than scaled, so a title keeps its
* real size and is clipped while there is no room, instead of smearing.
*
* Goes after the element's offset and before its size, so the travelling bounds
* drive the measurement.
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphEvent(key: ViewMorphKey.Event): Modifier {
val morph = LocalViewMorph.current ?: return this
if (key in morph.stayingPut.keys) return this
return with(morph.shared) {
this@morphEvent.sharedBounds(
sharedContentState = rememberSharedContentState(key, morph.config),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
zIndexInOverlay = 1f,
)
}
}
/** Tags a day's number, which is small on both sides, so it scales on its way. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.morphDay(date: LocalDate): Modifier {
val morph = LocalViewMorph.current ?: return this
return with(morph.shared) {
this@morphDay.sharedBounds(
sharedContentState = rememberSharedContentState(ViewMorphKey.Day(date), morph.config),
animatedVisibilityScope = morph.visibility,
enter = fadeIn(morph.fadeSpec),
exit = fadeOut(morph.fadeSpec),
boundsTransform = morph.boundsTransform,
clipInOverlayDuringTransition = morph.clip,
zIndexInOverlay = 1f,
)
}
}
/** The view switch's travel, on the same spring as the timeline's columns resizing. */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun rememberViewMorphBoundsTransform(): BoundsTransform {
val spec = rememberViewSwitchSpec<Rect>()
return remember(spec) { BoundsTransform { _, _ -> spec } }
}
@@ -1,57 +1,27 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.res.stringResource
/** /**
* 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
val fadeSpec = rememberCalendarFadeSpec()
FilledTonalButton( FilledTonalButton(
onClick = onCycle, onClick = onCycle,
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
modifier = modifier modifier = modifier,
.padding(end = trailingInset),
) { ) {
// The label cross-fades and the pill eases to its width, so cycling Text(stringResource(current.labelRes))
// views turns it over rather than snapping it (#184).
AnimatedContent(
targetState = current.label(),
transitionSpec = {
(fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec)).using(SizeTransform(clip = false))
},
contentAlignment = Alignment.Center,
) { label ->
Text(label)
}
} }
} }
@@ -1,22 +0,0 @@
package de.jeanlucmakiola.calendula.ui.day
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.daysUntil
import kotlinx.datetime.plus
/** Pages the day pager spans — a little under four centuries, centred on [PAGE_EPOCH]. */
internal const val DAY_PAGE_COUNT: Int = 140_000
private const val EPOCH_PAGE = DAY_PAGE_COUNT / 2
/** The day at [EPOCH_PAGE]; any date works, it only has to stay fixed. */
private val PAGE_EPOCH = LocalDate(2000, 1, 1)
/** The day shown on pager [page]. */
internal fun dayForPage(page: Int): LocalDate =
PAGE_EPOCH.plus(page - EPOCH_PAGE, DateTimeUnit.DAY)
/** The pager page showing [date]. */
internal fun dayPageFor(date: LocalDate): Int =
(EPOCH_PAGE + PAGE_EPOCH.daysUntil(date)).coerceIn(0, DAY_PAGE_COUNT - 1)
@@ -0,0 +1,817 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.TodayAction
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.beginsOn
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
import de.jeanlucmakiola.calendula.ui.common.startInstant
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import java.util.Locale
import kotlin.math.roundToInt
private val ALL_DAY_ROW_HEIGHT = 24.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). */
internal fun DayUiState.Success.allDayStripHeight(): Dp {
if (allDay.isEmpty()) return 0.dp
val lanes = allDay.maxOf { it.lane } + 1
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DayScreen(
selectedView: CalendarView,
onSelectView: (CalendarView) -> Unit,
onEventClick: (EventInstance) -> Unit,
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
todayInToolbar: Boolean = false,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val date by viewModel.date.collectAsStateWithLifecycle()
// When opened from the month grid, anchor to the tapped date.
LaunchedEffect(initialDateIso) {
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val drawerState = rememberDrawerState(DrawerValue.Closed)
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) {
is DayUiState.Success -> s.date == s.today
else -> true
}
// Drives whether the title carries the year. Falls back to the clock only
// while the first load is in flight, when there is no state to read today from.
val currentYear = when (val s = state) {
is DayUiState.Success -> s.today.year
else -> Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.year
}
// Slide direction for the day transition: +1 = next, -1 = prev, 0 = jump.
var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is DayUiState.Success -> if (s.today < s.date) -1 else 1
else -> 0
}
viewModel.goToToday()
}
// Drawer jump-to-date: slide from the side the target lies on.
val jumpToDate: (LocalDate) -> Unit = { target ->
slideDir = if (target < date) -1 else 1
viewModel.goToDate(target)
}
ModalNavigationDrawer(
drawerState = drawerState,
// Open only via the menu button — edge-swipe would fight the day swipe.
gesturesEnabled = drawerState.isOpen,
drawerContent = {
CalendarDrawer(
currentView = selectedView,
currentDate = date,
drawerState = drawerState,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
},
onJumpToDate = { target ->
jumpToDate(target)
scope.launch { drawerState.close() }
},
onSettings = {
onOpenSettings()
scope.launch { drawerState.close() }
},
)
},
) {
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
DayTopBar(
date = date,
currentYear = currentYear,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
onJumpToDate = jumpToDate,
showTodayButton = todayInToolbar,
onToday = jumpToToday,
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
CalendarFabColumn(
todayVisible = !isOnToday && !todayInToolbar,
todayText = stringResource(R.string.day_today_action),
onToday = jumpToToday,
onCreate = { onCreateEvent(date, null) },
)
},
) { innerPadding ->
DayContent(
state = state,
slideDir = slideDir,
topSectionColor = topSectionColor,
onSwipeNext = goNext,
onSwipePrev = goPrev,
onRetry = jumpToToday,
onEventClick = onEventClick,
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
)
}
}
}
@Composable
private fun DayContent(
state: DayUiState,
slideDir: Int,
topSectionColor: Color,
onSwipeNext: () -> Unit,
onSwipePrev: () -> Unit,
onRetry: () -> Unit,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
modifier: Modifier = Modifier,
) {
val slideSpec = rememberCalendarSlideSpec()
val fadeSpec = rememberCalendarFadeSpec()
val reduceMotion = rememberReduceMotion()
// Hoisted above the per-day AnimatedContent so the vertical scroll position
// survives day-to-day swipes. We only centre on noon once, on first entry
// into the day view (i.e. when arriving from the month/week view).
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
snapshotFlow { scrollState.maxValue }.first { it > 0 }
// Half the scroll range *is* noon: the content spans a full 24 hours, so
// centring the range centres midday at whatever hour height is in force.
scrollState.scrollTo(scrollState.maxValue / 2)
}
// Single, hoisted all-day strip height — shared by the outgoing and incoming
// day during a swipe, so the strip slides along but never jumps in height.
val targetAllDayHeight = (state as? DayUiState.Success)?.allDayStripHeight() ?: 0.dp
val allDayHeight by animateDpAsState(
targetValue = targetAllDayHeight,
label = "day-all-day-strip-height",
)
// Whole-page horizontal swipe, one level above the timeline's vertical
// scroll: a horizontal drag crosses this detector's slop, while a vertical
// drag is consumed by the inner scroll first — the two gestures coexist.
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
// Above the AnimatedContent: a page change mid-drag would strand the
// floating block inside the outgoing page.
val dragController = rememberTimelineDragController()
val move = LocalEventMove.current
val zone = remember { TimeZone.currentSystemDefault() }
Box(modifier = modifier) {
AnimatedContent(
targetState = state,
modifier = Modifier.fillMaxSize().then(swipeModifier),
contentKey = { s ->
when (s) {
is DayUiState.Success -> "success-${s.date}"
is DayUiState.Failure -> "failure-${s.reason}"
DayUiState.Loading -> "loading"
}
},
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
label = "day-transition",
) { s ->
when (s) {
DayUiState.Loading -> DayLoading()
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
is DayUiState.Success -> DaySuccess(
state = s,
topSectionColor = topSectionColor,
scrollState = scrollState,
allDayHeight = allDayHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = { drop ->
val took = move?.move(
MoveRequest(
eventId = drop.event.eventId,
beginMillis = drop.event.start.toEpochMilliseconds(),
endMillis = drop.event.end.toEpochMilliseconds(),
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()
},
)
}
}
TimelineDragOverlay(dragController)
}
}
@Composable
internal fun DaySuccess(
state: DayUiState.Success,
topSectionColor: Color,
scrollState: ScrollState,
allDayHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events,
// so the timeline sits directly under the app bar.
AllDayStrip(
state = state,
height = allDayHeight,
onEventClick = onEventClick,
modifier = Modifier
.fillMaxWidth()
.background(topSectionColor),
)
// Breathing room between the (colour-shifting) top section and the
// scrolling timeline below.
Spacer(Modifier.height(8.dp))
Timeline(
state = state,
scrollState = scrollState,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DayTopBar(
date: LocalDate,
currentYear: Int,
selectedView: CalendarView,
onCycleView: () -> Unit,
onOpenDrawer: () -> Unit,
onOpenSearch: () -> Unit,
onJumpToDate: (LocalDate) -> Unit,
showTodayButton: Boolean,
onToday: () -> Unit,
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
) {
val locale = currentLocale()
TopAppBar(
title = {
CalendarTitleButton(
title = formatDayTitle(date, locale, currentYear),
currentDate = date,
onJumpToDate = onJumpToDate,
)
},
navigationIcon = {
IconButton(onClick = onOpenDrawer) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.month_open_menu),
)
}
},
actions = {
TodayAction(show = showTodayButton, onToday = onToday)
IconButton(onClick = onOpenSearch) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search_action),
)
}
ViewSwitcherPill(
current = selectedView,
onCycle = onCycleView,
modifier = Modifier.padding(end = 8.dp),
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
),
scrollBehavior = scrollBehavior,
)
}
@Composable
private fun AllDayStrip(
state: DayUiState.Success,
height: Dp,
onEventClick: (EventInstance) -> Unit,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
Row(
modifier = modifier
// Height is hoisted + animated so it resizes smoothly; padding sits
// inside it so the content area is lanes * row height.
.height(height)
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
) {
// Keep the gutter-width offset so the bars line up with the day column.
Spacer(Modifier.width(GUTTER_WIDTH))
// Bars are positioned absolutely by lane (vertical stacking); each spans
// the full day-column width. clipToBounds keeps bars from spilling out
// while the height animates.
BoxWithConstraints(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clipToBounds(),
) {
val barWidth = maxWidth
state.allDay.forEach { span ->
AllDayBar(
event = span.event,
dark = dark,
onClick = { onEventClick(span.event) },
modifier = Modifier
.offset(y = ALL_DAY_ROW_HEIGHT * span.lane)
.width(barWidth)
.height(ALL_DAY_ROW_HEIGHT)
.padding(horizontal = 1.dp, vertical = 1.dp),
)
}
}
}
}
@Composable
private fun AllDayBar(
event: EventInstance,
dark: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box(
modifier = modifier
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = title },
contentAlignment = Alignment.CenterStart,
) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill),
)
}
}
@Composable
private fun Timeline(
state: DayUiState.Success,
scrollState: ScrollState,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
) {
val dark = isSystemInDarkTheme()
val zoom = LocalTimelineZoom.current
val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top
// bar, date header and all-day strip.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val hourHeight = zoom.scale.hourHeight(maxHeight)
val totalHeight = hourHeight * 24
// The pinch sits on the Row, above both scroll viewports: it has to
// outrank the vertical scroll, and it does that by watching the initial
// pass, which only reaches it if it is their ancestor.
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
// Gutter and day column are two scroll viewports that SHARE one scroll
// state, so they stay perfectly aligned. The day-column viewport is a
// static, rounded-clipped window — the content scrolls inside it, so the
// soft corners are permanent at any scroll position.
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
// Hour gutter (scrolls in sync with the day column). Start inset so the
// labels centre on the top bar hamburger, matching the week view.
HourGutter(
scrollState = scrollState,
hourHeight = hourHeight,
dragController = dragController,
)
// Day column: rounded, clipped scroll viewport (permanent corners).
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(16.dp))
.verticalScroll(scrollState)
.onGloballyPositioned { dragController.geometry.viewport = it },
) {
DayColumnCard(
blocks = state.timed,
dark = dark,
date = state.date,
today = state.today,
hourHeight = hourHeight,
dragController = dragController,
onEventClick = onEventClick,
onCreateAt = onCreateAt,
onDrop = onDrop,
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
// The scrolling content itself, so its root position
// already folds in the scroll offset.
.onGloballyPositioned { coords ->
dragController.geometry.let {
it.grid = coords
it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() }
it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date)
it.isRtl = isRtl
}
},
)
}
}
}
}
@Composable
private fun DayColumnCard(
blocks: List<TimedBlock>,
dark: Boolean,
date: LocalDate,
today: LocalDate,
hourHeight: Dp,
dragController: TimelineDragController,
onEventClick: (EventInstance) -> Unit,
onCreateAt: (LocalDate, Int) -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourLines = LocalShowHourLines.current
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) {
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(
// Plain rectangular column — the soft corners come from the outer
// rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
modifier = modifier,
) {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
// Faint hour separators sit over the column background but under
// the event blocks (drawBehind paints before the children).
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// Tap an empty slot to create an event there. Taps on event
// blocks are consumed by their own click handler first, so this
// only fires on the column background. Snaps to the tapped hour.
.pointerInput(date) {
detectTapGestures { offset ->
currentOnCreateAt.value(
date,
tappedMinuteOfDay(offset.y, currentHourPx.value),
)
}
},
) {
val colWidth = maxWidth
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
// Keyed by event, so a block that changes time or lane is the *same*
// composable afterwards and tweens there. The ordinal disambiguates
// a column holding two occurrences of one series.
val ordinals = mutableMapOf<Long, Int>()
blocks.forEach { block ->
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
key(block.event.eventId, ordinal) {
val laneWidth = colWidth / block.laneCount
val top = hourHeight * (block.startMin / 60f)
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
val place = animatedBlockPlacement(
x = laneWidth * block.lane,
y = top,
width = laneWidth,
height = height,
)
EventBlock(
block = block,
dark = dark,
height = place.height,
date = date,
dragController = dragController,
onClick = { onEventClick(block.event) },
onDrop = onDrop,
modifier = Modifier
.offset(x = place.x, y = place.y)
.width(place.width)
.height(place.height)
.padding(horizontal = 1.dp),
)
}
}
// Current-time line, on top of the events, only on today's column.
if (date == today) {
NowLine(date = date, hourHeight = hourHeight)
}
}
}
}
@Composable
private fun EventBlock(
block: TimedBlock,
dark: Boolean,
height: Dp,
date: LocalDate,
dragController: TimelineDragController,
onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit,
modifier: Modifier = Modifier,
) {
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
minToHm(block.endMin, use24Hour, locale)
val density = LocalDensity.current
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// 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
// short even for that drops the title rather than serving a sliced one.
val available = height - 4.dp
val showTime = block.endMin - block.startMin >= 45 &&
available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event)
// A block clipped at the top continues from the previous day: its top edge
// is midnight, not the event's start, so dragging it would invent a time.
val draggable = eventDragAllowed(block.event) && block.beginsOn(date, zone)
val dragModifier = rememberEventDragSource(
enabled = draggable,
key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) },
onCancel = dragController::cancel,
)
val lifted = draggable && dragController.ghosts(block, date)
val ghost = ghostAlpha(lifted)
Box(
modifier = modifier
// The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, RoundedCornerShape(4.dp))
.clickable(onClick = onClick)
// After clickable, so it is the inner node and wins the main pass;
// the tap still works, since a drag consumes the up.
.then(dragModifier)
.padding(horizontal = 4.dp, vertical = 2.dp)
.semantics {
contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction)
},
) {
Column {
if (showTitle) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = if (showTime) 1 else 2,
overflow = TextOverflow.Ellipsis,
color = eventInk(fill, alpha = 0.85f),
)
}
if (showTime) {
BlockTimeLabel(
label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
)
}
}
}
}
@Composable
private fun DayLoading() {
val scale = LocalTimelineZoom.current.scale
val scrollState = rememberScrollState()
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives.
val totalHeight = scale.hourHeight(maxHeight) * 24
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
Spacer(Modifier.width(GUTTER_WIDTH))
Box(
modifier = Modifier
.weight(1f)
.height(totalHeight)
.padding(horizontal = 2.dp)
.background(MaterialTheme.colorScheme.surfaceContainer),
)
}
}
}
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
formatMinuteOfDay(min, is24Hour, locale)
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
formatCalendarTitle(
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
locale = locale,
currentYear = currentYear,
skeleton = "EEEdMMM",
)
@@ -0,0 +1,25 @@
package de.jeanlucmakiola.calendula.ui.day
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.AllDaySpan
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.datetime.LocalDate
/**
* The day view is a single-column slice of the week view (spec S3). It reuses the
* week's [TimedBlock] and [AllDaySpan] layout primitives — for one day, all-day
* spans collapse to a single column ([AllDaySpan.startCol] == [AllDaySpan.endCol]
* == 0) and only their [AllDaySpan.lane] (vertical stacking) matters.
*/
sealed interface DayUiState {
data object Loading : DayUiState
data class Failure(val reason: FailureReason) : DayUiState
data class Success(
val date: LocalDate,
val today: LocalDate,
/** All-day/multi-day events covering this day, stacked by lane. */
val allDay: List<AllDaySpan>,
/** Timed events clipped to this day with overlap lanes resolved. */
val timed: List<TimedBlock>,
) : DayUiState
}
@@ -0,0 +1,111 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.atTime
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class DayViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val zone = TimeZone.currentSystemDefault()
private val todayDate: LocalDate
get() = Clock.System.now().toLocalDateTime(zone).date
private val _date = MutableStateFlow(todayDate)
val date: StateFlow<LocalDate> = _date
val state: StateFlow<DayUiState> = _date
.flatMapLatest { day ->
val range = dayRange(day, zone)
combine(
repository.calendars(),
repository.instances(range),
) { calendars, instances ->
buildState(day, calendars, instances)
}
}
.catch { emit(DayUiState.Failure(FailureReason.ProviderUnavailable)) }
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = DayUiState.Loading,
)
fun goToPrev() {
_date.value = _date.value.minus(1, DateTimeUnit.DAY)
}
fun goToNext() {
_date.value = _date.value.plus(1, DateTimeUnit.DAY)
}
fun goToToday() {
_date.value = todayDate
}
/** Jump to a specific date (e.g. when opened from the month grid). */
fun goToDate(date: LocalDate) {
_date.value = date
}
private fun buildState(
day: LocalDate,
calendars: List<CalendarSource>,
instances: List<EventInstance>,
): DayUiState {
if (calendars.isEmpty()) {
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val days = listOf(day)
val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay }
return DayUiState.Success(
date = day,
today = todayDate,
allDay = layoutAllDay(allDay, days, zone),
timed = layoutDay(timed, day, zone),
)
}
}
/** Half-open instant range covering the single calendar [date]. */
internal fun dayRange(date: LocalDate, zone: TimeZone): ClosedRange<Instant> {
val from = date.atStartOfDayIn(zone)
val to = date.atTime(23, 59, 59).toInstant(zone)
return from..to
}
@@ -0,0 +1,65 @@
package de.jeanlucmakiola.calendula.ui.day
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
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,
topSectionColor = MaterialTheme.colorScheme.surface,
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),
)
}
@@ -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
@@ -140,7 +138,6 @@ fun EventDetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onDuplicate: (EventForm) -> Unit, onDuplicate: (EventForm) -> Unit,
visible: Boolean = true,
viewModel: EventDetailViewModel = hiltViewModel(), viewModel: EventDetailViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(eventId, beginMillis, endMillis) { LaunchedEffect(eventId, beginMillis, endMillis) {
@@ -153,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.
@@ -228,7 +224,7 @@ fun EventDetailScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.predictiveBack(onBack = onBack, visible = visible), modifier = Modifier.predictiveBack(onBack = onBack),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -303,9 +299,8 @@ fun EventDetailScreen(
is EventDetailUiState.Failure -> CalendarFailure( is EventDetailUiState.Failure -> CalendarFailure(
reason = s.reason, reason = s.reason,
onRetry = viewModel::retry, onRetry = viewModel::retry,
modifier = contentModifier,
) )
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier) is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
} }
} }
@@ -387,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()
@@ -408,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,
@@ -418,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))
@@ -528,8 +508,7 @@ private fun EventDetailContent(
iconContentDescription = stringResource(R.string.event_detail_calendar), iconContentDescription = stringResource(R.string.event_detail_calendar),
) { ) {
Text( Text(
text = state.calendarName?.ifBlank { stringResource(R.string.calendar_unnamed) } text = state.calendarName ?: stringResource(R.string.event_detail_calendar_unknown),
?: stringResource(R.string.event_detail_calendar_unknown),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
} }
@@ -537,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,
@@ -549,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),
) )
} }
@@ -562,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),
@@ -643,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),
@@ -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. */
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -65,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
@@ -91,7 +89,6 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
@@ -103,7 +100,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -203,7 +199,6 @@ fun EventEditScreen(
initialForm: EventForm? = null, initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File, initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null, onManageCalendars: (() -> Unit)? = null,
visible: Boolean = true,
viewModel: EventEditViewModel = hiltViewModel(), viewModel: EventEditViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(initialDateIso, editKey, initialForm) { LaunchedEffect(initialDateIso, editKey, initialForm) {
@@ -269,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
@@ -286,7 +278,7 @@ fun EventEditScreen(
} }
Scaffold( Scaffold(
modifier = Modifier.predictiveBack(onBack = close, visible = visible), modifier = Modifier.predictiveBack(onBack = close),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -621,9 +613,7 @@ private fun EventEditContent(
Text( Text(
text = stringResource( text = stringResource(
R.string.event_edit_managed_hint, R.string.event_edit_managed_hint,
selectedCalendar?.displayName selectedCalendar?.displayName.orEmpty(),
?.ifBlank { stringResource(R.string.calendar_unnamed) }
.orEmpty(),
), ),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -722,7 +712,6 @@ private fun EventEditContent(
) { ) {
Text( Text(
text = selectedCalendar?.displayName text = selectedCalendar?.displayName
?.ifBlank { stringResource(R.string.calendar_unnamed) }
?: stringResource(R.string.event_edit_error_no_calendar), ?: stringResource(R.string.event_edit_error_no_calendar),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = if (selectedCalendar == null) { color = if (selectedCalendar == null) {
@@ -795,59 +784,38 @@ private fun EventEditContent(
EditCard( EditCard(
icon = Icons.Default.Place, icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location), iconContentDescription = stringResource(R.string.event_detail_location),
iconAtTop = true,
) { ) {
Row(verticalAlignment = Alignment.Top) { Row(verticalAlignment = Alignment.CenterVertically) {
InlineField( InlineField(
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,
// Multi-line so a long address or meeting URL wraps and
// the card grows instead of scrolling off one line
// (#273). The field holds no newlines of its own, so
// the IME's action key closes the keyboard rather than
// inserting a break setLocation would only fold away.
singleLine = false,
imeAction = ImeAction.Done,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.animateContentSizeMotion()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
) )
// The box is exactly one text line tall, so the button IconButton(
// centres on the first line without making a single-line onClick = {
// card taller than its text; requiredSize keeps the runCatching {
// button's own 40dp ripple. pickContactAddress.launch(
Box( Intent(
modifier = Modifier.height(firstLineHeight()), Intent.ACTION_PICK,
contentAlignment = Alignment.Center, ContactsContract.CommonDataKinds.StructuredPostal
.CONTENT_URI,
),
)
}
},
modifier = Modifier.size(40.dp),
) { ) {
IconButton( Icon(
onClick = { imageVector = Icons.Default.Contacts,
runCatching { contentDescription = stringResource(
pickContactAddress.launch( R.string.event_edit_location_from_contacts,
Intent( ),
Intent.ACTION_PICK, tint = MaterialTheme.colorScheme.onSurfaceVariant,
ContactsContract.CommonDataKinds.StructuredPostal modifier = Modifier.size(22.dp),
.CONTENT_URI, )
),
)
}
},
modifier = Modifier.requiredSize(40.dp),
) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(
R.string.event_edit_location_from_contacts,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
} }
} }
} }
@@ -2017,8 +1985,8 @@ private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>?
/** /**
* Read the formatted postal address from a contact-pick result URI. No * Read the formatted postal address from a contact-pick result URI. No
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access. * READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
* The provider's formatted address is multi-line; collapse it to one comma- * The provider's formatted address is multi-line; collapse it to one line so
* separated line, which is what the location field holds. * it sits cleanly in the single-line location field.
*/ */
private fun readContactAddress(context: Context, uri: Uri): String? = private fun readContactAddress(context: Context, uri: Uri): String? =
context.contentResolver.query( context.contentResolver.query(
@@ -2202,19 +2170,14 @@ private fun EditCard(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically, verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) { ) {
// A top-aligned icon centres on the first text line, whatever
// that line measures at the user's font scale.
val iconOffset = if (iconAtTop) {
((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp)
} else {
0.dp
}
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = iconContentDescription, contentDescription = iconContentDescription,
tint = iconTint, tint = iconTint,
// 4dp mirrors InlineField's vertical padding, so a
// top-aligned icon (24dp) centres on the ~24sp first line.
modifier = Modifier modifier = Modifier
.padding(top = iconOffset) .padding(top = if (iconAtTop) 4.dp else 0.dp)
.size(24.dp), .size(24.dp),
) )
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
@@ -2256,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(
@@ -2270,8 +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,
imeAction: ImeAction = ImeAction.Default,
modifier: Modifier = Modifier modifier: Modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
@@ -2286,25 +2245,10 @@ private fun InlineField(
minLines = minLines, minLines = minLines,
enabled = enabled, enabled = enabled,
keyboardType = keyboardType, keyboardType = keyboardType,
capitalization = capitalization, capitalization = KeyboardCapitalization.None,
imeAction = imeAction,
) )
} }
/**
* Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the
* field's 4dp vertical padding. In dp, so it tracks the user's font scale
* instead of assuming the 24sp line of a 1.0 scale (#165).
*/
@Composable
private fun firstLineHeight(
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
): Dp {
val lineHeight = textStyle.lineHeight
if (!lineHeight.isSp) return 32.dp
return with(LocalDensity.current) { lineHeight.toDp() } + 8.dp
}
/** One schedule row: label, then tappable date and (unless all-day) time. */ /** One schedule row: label, then tappable date and (unless all-day) time. */
@Composable @Composable
private fun ScheduleRow( private fun ScheduleRow(
@@ -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,11 +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"
/** Any line break, with the whitespace around it, as one match. */
private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""")
/** /**
* 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
@@ -535,11 +529,7 @@ class EventEditViewModel @Inject constructor(
// paste would introduce, so it never reaches the provider's TITLE column. // paste would introduce, so it never reaches the provider's TITLE column.
fun setTitle(value: String) = fun setTitle(value: String) =
update { it.copy(title = value.replace("\n", "").replace("\r", "")) } update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
// The location wraps too (#273) but is likewise one logical line. A pasted fun setLocation(value: String) = update { it.copy(location = value) }
// multi-line address joins the way the contact picker's does (#146), so it
// never runs together into "12 Main St10115 Berlin".
fun setLocation(value: String) =
update { it.copy(location = value.replace(LINE_BREAK, ", ")) }
fun setDescription(value: String) = update { it.copy(description = value) } fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) { fun setAllDay(value: Boolean) {
// Going all-day drops any pinned zone: the times become bare dates that // Going all-day drops any pinned zone: the times become bare dates that
@@ -744,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,
@@ -766,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
} }
} }
@@ -62,16 +62,15 @@ private fun FilterList(
) { ) {
Column(modifier = modifier.fillMaxWidth()) { Column(modifier = modifier.fillMaxWidth()) {
groups.forEach { group -> groups.forEach { group ->
val account = group.account.ifBlank { stringResource(R.string.calendar_unnamed) }
Text( Text(
text = if (group.ambiguous) { text = if (group.ambiguous) {
stringResource( stringResource(
R.string.calendars_account_from_source, R.string.calendars_account_from_source,
account, group.account,
sourceAppName(group.accountType), sourceAppName(group.accountType),
) )
} else { } else {
account group.account
}, },
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -84,7 +83,7 @@ private fun FilterList(
) )
group.calendars.forEachIndexed { index, cal -> group.calendars.forEachIndexed { index, cal ->
GroupedRow( GroupedRow(
title = cal.displayName.ifBlank { stringResource(R.string.calendar_unnamed) }, title = cal.displayName,
position = positionOf(index, group.calendars.size), position = positionOf(index, group.calendars.size),
minHeight = 56.dp, minHeight = 56.dp,
leading = { CalendarColorChip(cal.color) }, leading = { CalendarColorChip(cal.color) },
@@ -126,7 +125,6 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) { private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
val msg = when (reason) { val msg = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
FailureReason.PermissionRevoked -> R.string.state_failure_permission FailureReason.PermissionRevoked -> R.string.state_failure_permission
else -> R.string.state_failure_provider else -> R.string.state_failure_provider
} }
@@ -8,7 +8,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -36,9 +35,8 @@ class FilterViewModel @Inject constructor(
// Calendars switched off device-wide don't belong in the drawer's // Calendars switched off device-wide don't belong in the drawer's
// hide/show list; they live only in Settings → Calendars. // hide/show list; they live only in Settings → Calendars.
val enabled = calendars.filter { it.isVisibleInSystem } val enabled = calendars.filter { it.isVisibleInSystem }
val failure = calendarListFailure(calendars) if (enabled.isEmpty()) {
if (failure != null) { FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
FilterUiState.Failure(failure)
} else { } else {
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden)) FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
} }

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