Compare commits
92 Commits
v2.17.1
...
2359efaef6
| Author | SHA1 | Date | |
|---|---|---|---|
| 2359efaef6 | |||
|
|
398cfc8906 | ||
| c0b7ad29ef | |||
| bc49730ea5 | |||
| a1d1894f84 | |||
|
|
a2172dce12 | ||
|
|
4099f66e91 | ||
| 49112b11b3 | |||
| 1218bb523c | |||
| 17600a3905 | |||
|
|
db5104a7d0 | ||
|
|
6306064fc3 | ||
|
|
d5a4761f2c | ||
|
|
66d6681798 | ||
|
|
fb61169656 | ||
|
|
62079fba0b | ||
|
|
cc0fc8edc3 | ||
|
|
62174fcd53 | ||
|
|
55c1b2ac25 | ||
|
|
0ccc610144 | ||
|
|
5d009b7dc0 | ||
|
|
28924daf02 | ||
|
|
e71fb2a625 | ||
|
|
aa5f76fc1b | ||
|
|
5b099714f6 | ||
| 163d821d1f | |||
|
|
fe98b1ed66 | ||
|
|
c0270ab3a0 | ||
|
|
6019d6c0b8 | ||
|
|
19bc5967a4 | ||
|
|
ddab3454bb | ||
|
|
247c9ee6cd | ||
|
|
6be170cae2 | ||
|
|
8133584f1e | ||
|
|
4cdb42afba | ||
|
|
19a4c05e93 | ||
|
|
aba06affbf | ||
|
|
2b132a5b7a | ||
|
|
52ecce03b8 | ||
|
|
0eb76db12d | ||
|
|
d03985eada | ||
|
|
34108b9c50 | ||
| 49a0c114c2 | |||
|
|
b1ce6f85cc | ||
|
|
ca5f87af11 | ||
|
|
7445092767 | ||
|
|
5ef66120bf | ||
|
|
bda86cd73d | ||
|
|
56953b0c50 | ||
|
|
7c8cd1e145 | ||
|
|
bce36956d1 | ||
|
|
e0fdadc7ca | ||
|
|
136167b54c | ||
| 02fd599e6b | |||
| b0412a96f3 | |||
|
|
06dbb89868 | ||
|
|
b002f45f29 | ||
|
|
c1eb702384 | ||
|
|
307517e85a | ||
|
|
a8ba227e8b | ||
|
|
cbfdfb9710 | ||
| d85003416e | |||
|
|
61c516149e | ||
|
|
560225cb8c | ||
|
|
2e8367afd4 | ||
| 8c40109341 | |||
|
|
4259c996b6 | ||
| d4218227bd | |||
|
|
6a4c5084a5 | ||
|
|
e38b9cde1f | ||
|
|
dbae8b5bcf | ||
|
|
2d06b7ad32 | ||
|
|
10a674deba | ||
|
|
93b9aa8f34 | ||
| ca826737ed | |||
| 5365cc67eb | |||
|
|
25e3640ba0 | ||
| 3f85bac74e | |||
| 5b83b9da73 | |||
| 3c8c555b94 | |||
| a6a6c0c714 | |||
|
|
66dbdc8913 | ||
|
|
6a125b7c73 | ||
|
|
44233cff35 | ||
|
|
5764bd889b | ||
|
|
a8a83a39d0 | ||
|
|
9557ca73ed | ||
|
|
9767fbbbaf | ||
|
|
9054742503 | ||
|
|
3d62036d79 | ||
|
|
34a8e91ea5 | ||
|
|
0097a9c534 |
@@ -264,9 +264,10 @@ jobs:
|
|||||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
||||||
|
|
||||||
# Per-version "What's New": ensure this version's changelog exists in the
|
# Per-version "What's New": ensure this version's changelog exists in the
|
||||||
# fastlane tree (committed at release-cut time for the official repo; this
|
# fastlane tree. The committed hand-written summary (kept under Play's
|
||||||
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
|
# 500-char cap) is used as-is; only if it is missing does the script fall
|
||||||
# the commit having happened). The transform below then carries it across.
|
# back to CHANGELOG.md, so the self-hosted repo never depends on the
|
||||||
|
# commit having happened. The transform below then carries it across.
|
||||||
- name: Ensure this version's changelog is in the fastlane tree
|
- name: Ensure this version's changelog is in the fastlane tree
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: bash scripts/sync_changelog_to_fastlane.sh
|
run: bash scripts/sync_changelog_to_fastlane.sh
|
||||||
@@ -550,12 +551,11 @@ 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. `internal` by default so a release reaches
|
# Where the bundle lands. `production` — the release itself is already the
|
||||||
# testers rather than the public, and promotion to production stays a
|
# gate (a bumped versionName only reaches main after on-device review), so
|
||||||
# deliberate human action in the Play Console — the same posture as
|
# a second manual promotion in the Play Console bought nothing but delay.
|
||||||
# holding UI releases for on-device review. Override with the PLAY_TRACK
|
# Override with the PLAY_TRACK repo variable to stage a release instead.
|
||||||
# repo variable once the flow is trusted.
|
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
|
||||||
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.
|
||||||
@@ -635,9 +635,13 @@ jobs:
|
|||||||
FASTLANE_HIDE_CHANGELOG: '1'
|
FASTLANE_HIDE_CHANGELOG: '1'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
test -f "dist/app-release.aab"
|
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
|
||||||
|
# Absolute, because a lane body runs from fastlane/, not the
|
||||||
|
# workspace root — a relative path resolves against the wrong
|
||||||
|
# directory there and 2.17.1 died on exactly that.
|
||||||
|
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
||||||
bundle exec fastlane deploy \
|
bundle exec fastlane deploy \
|
||||||
aab:"dist/app-release.aab" \
|
aab:"$AAB" \
|
||||||
track:"$PLAY_TRACK" \
|
track:"$PLAY_TRACK" \
|
||||||
release_status:"$PLAY_RELEASE_STATUS" \
|
release_status:"$PLAY_RELEASE_STATUS" \
|
||||||
dry_run:"$PLAY_DRY_RUN"
|
dry_run:"$PLAY_DRY_RUN"
|
||||||
|
|||||||
240
CHANGELOG.md
240
CHANGELOG.md
@@ -7,6 +7,227 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **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]).
|
||||||
|
|
||||||
|
## [2.19.3] — 2026-08-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Czech, Japanese and Russian**, all three contributed as community
|
||||||
|
translations through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
They are early — Czech covers about a third of the app, Russian a fifth,
|
||||||
|
Japanese has just started — so untranslated parts still show in English until
|
||||||
|
they fill out. Pick any of them under Settings → Language or in Android's
|
||||||
|
per-app language settings. Thanks to James Trivette (Czech), ame0011
|
||||||
|
(Japanese), and chip and Pablo (Russian); help finishing them is very welcome.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Brazilian Portuguese is now complete**, covering the whole app. Thanks to
|
||||||
|
bkrz for carrying it the rest of the way.
|
||||||
|
|
||||||
|
## [2.19.2] — 2026-08-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **A meeting you declined is now struck through** wherever it appears — month,
|
||||||
|
week, day, agenda, search and both widgets — and no longer schedules a
|
||||||
|
reminder. Declining an invitation in Google Calendar left the event looking
|
||||||
|
like any other in Calendula, and it still notified you about a meeting you had
|
||||||
|
said no to. It stays visible rather than disappearing: the organiser still
|
||||||
|
expects an answer from you, and the slot is still spoken for ([#180]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Tapping an event in month view opens the event**, not the day it sits on.
|
||||||
|
Every month style is affected — page, rolling, seamless weeks — and until now
|
||||||
|
the only way to reach an event from the month was to open its day first and
|
||||||
|
find it again there. Tapping anywhere else in the cell still opens the day
|
||||||
|
([#187]).
|
||||||
|
- **An edit made to an event now shows the moment you re-open it.** Adding a
|
||||||
|
description to an event and opening it again showed the sheet as it was before
|
||||||
|
the save, because the detail was only re-read when a different occurrence was
|
||||||
|
opened ([#196]).
|
||||||
|
- **The month widget's arrows stopped working after a couple of taps.** The grid
|
||||||
|
was serialised as roughly 740 views, and one update ran to half a megabyte —
|
||||||
|
more than the launcher's buffer takes. The third update overran it, and Android
|
||||||
|
responded by dropping the whole widget host, which killed updates for *every*
|
||||||
|
widget on the home screen, ours and other apps', until the launcher rebound.
|
||||||
|
The grid now draws 192 views, and a resized widget reflows to its new size
|
||||||
|
instead of clipping ([#214]).
|
||||||
|
- Tapping a widget's refresh or month arrows redraws that widget by its own id
|
||||||
|
instead of asking Android to update all of them, which does nothing in a
|
||||||
|
process the tap has just woken from cold ([#18]).
|
||||||
|
- **Jump-to-today in seamless weeks lands on the current week** instead of
|
||||||
|
leaving a sliver of the previous row on screen ([#191]).
|
||||||
|
- Day and week view no longer run their events flush against the right edge
|
||||||
|
([#192]).
|
||||||
|
|
||||||
|
## [2.19.1] — 2026-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Drag to reschedule can be turned off.** Settings → Views → *Drag to
|
||||||
|
reschedule*, on by default. Off, no event can be picked up in the month, week
|
||||||
|
or day view — for anyone who kept moving appointments by accident while
|
||||||
|
scrolling ([#173]).
|
||||||
|
- An early Brazilian Portuguese translation. Calendula has started speaking
|
||||||
|
Brazilian Portuguese, contributed as a community translation through
|
||||||
|
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||||
|
About a third of the app is covered, so the rest still shows in English — you
|
||||||
|
can pick it under Settings → Language or in Android's per-app language
|
||||||
|
settings. Thanks to
|
||||||
|
[bkrz](https://weblate.dev.jeanlucmakiola.de/user/bkrz/) for getting it
|
||||||
|
started; help finishing it is very welcome.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The language picker forgot which language was picked.** Android hands the
|
||||||
|
applied language back with its script filled in — Simplified Chinese goes in
|
||||||
|
as `zh-CN` and comes out as `zh-Hans-CN` — so after a restart no entry in
|
||||||
|
Settings → Language showed as selected, and the row above it named the
|
||||||
|
language the long way round. Only languages that imply a script were
|
||||||
|
affected.
|
||||||
|
|
||||||
|
## [2.19.0] — 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Drag an event to reschedule it.** Press and hold an event in the month,
|
||||||
|
week or day view and drop it on another time or day — the only way to move an
|
||||||
|
appointment used to be opening it and editing its start and end by hand. The
|
||||||
|
block follows your finger, the timeline scrolls when you carry it to an edge,
|
||||||
|
and the event's length is preserved: a 90-minute meeting dropped on 09:00 ends
|
||||||
|
at 10:30. In week and day view the drop snaps to 15-minute steps; in month
|
||||||
|
view it moves the event to the day you dropped it on and leaves the time
|
||||||
|
alone. A confirmation names the new slot and offers **Undo** wherever the move
|
||||||
|
can be taken back in one step. Events you can't edit — read-only calendars, and
|
||||||
|
birthdays and anniversaries mirrored from your contacts — can't be picked up.
|
||||||
|
Rescheduling isn't pointer-only: a screen reader gets a **Move…** action on
|
||||||
|
every event that can be moved ([#68]).
|
||||||
|
- Dragging a **recurring** event asks what it should apply to, the same *this
|
||||||
|
event / this and following / all events* choice a save does. Where moving the
|
||||||
|
whole series would change which weekday it falls on, the rule is rewritten to
|
||||||
|
match — a weekly Monday event dropped on a Wednesday becomes a weekly
|
||||||
|
Wednesday event. Where it can't be rewritten safely, the prompt says so and
|
||||||
|
offers to move just the one occurrence, rather than quietly leaving the
|
||||||
|
series behind ([#68]).
|
||||||
|
- **Delete several search results at once.** Long-press a hit in search to enter
|
||||||
|
selection mode, tap the rest (or **Select all**), and delete the lot in one
|
||||||
|
action — finding and removing, say, every leftover event with the same title
|
||||||
|
no longer means opening them one at a time. Recurring hits in the batch ask
|
||||||
|
once for the whole selection instead of once each, results on read-only
|
||||||
|
calendars can't be selected, and the deletion is confirmed before it runs
|
||||||
|
([#80]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **First launch is now a short wizard** instead of a single permission screen.
|
||||||
|
It counts its steps, each one can be stepped back out of, and beyond granting
|
||||||
|
calendar access it offers two things worth deciding once: turning on automatic
|
||||||
|
backup — chosen inline, folder picker and all, since events that live only on
|
||||||
|
the phone are lost with it — and choosing which view Calendula opens on, with
|
||||||
|
a live preview of the month, week, day and agenda layouts rather than four
|
||||||
|
names in a list. Both are skippable, everything chosen is changeable in
|
||||||
|
Settings afterwards, and the backup step is offered only where nothing
|
||||||
|
writable is being synced anywhere. Existing installs are not re-onboarded —
|
||||||
|
revoking and re-granting the calendar permission later does not restart the
|
||||||
|
wizard ([#163]).
|
||||||
|
- The one-time notice about calendars switched off for this device is now a step
|
||||||
|
in the flow rather than a dialog thrown over the app on first open.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Removed the deprecated `statusBarColor` / `navigationBarColor` theme
|
||||||
|
attributes the Play Console flags on Android 15 and up, where they no longer
|
||||||
|
do anything. The launch backdrop now picks its status-bar icons from the
|
||||||
|
light/dark resource instead, so they stay legible over it either way
|
||||||
|
([#123]).
|
||||||
|
|
||||||
|
## [2.18.1] — 2026-08-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Tapping an empty slot in week or day view creates the event at the hour you
|
||||||
|
tapped, after the timeline has been zoomed. Pinching the day taller or shorter
|
||||||
|
— or changing **Hour height** in Settings — left the tap still being measured
|
||||||
|
against the old spacing, so the new event landed at some other hour entirely
|
||||||
|
([#148]).
|
||||||
|
|
||||||
|
## [2.18.0] — 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- A new event no longer always lasts an hour. **Settings → New event form →
|
||||||
|
Default duration** sets how long one opens, and each calendar may keep its own
|
||||||
|
length underneath — 8 hours for the calendar you keep work shifts in, 30
|
||||||
|
minutes for the one you book calls in. A calendar without its own length
|
||||||
|
follows the default, switching calendars mid-form re-stretches the event, and
|
||||||
|
setting an end time by hand keeps it. An event another app hands over with only
|
||||||
|
a start gets the default too; one that names its own end — an `.ics` file, a
|
||||||
|
duplicate — keeps that length. All-day events are unaffected ([#54]).
|
||||||
|
- Week and day view can be set to show more or less of the day at once, under
|
||||||
|
Settings → Views → Week & day → **Hour height**. **Fit whole day** sizes an
|
||||||
|
hour to your screen so all 24 hours are visible without scrolling — on a tall
|
||||||
|
phone the old fixed spacing showed only about half a day, so appointments
|
||||||
|
could sit below the fold all week. Compact and Comfortable are fixed steps
|
||||||
|
either side of the previous spacing, which stays the default. Both views share
|
||||||
|
the setting ([#56]).
|
||||||
|
- Week and day view can be **pinched** with two fingers to set the hour height
|
||||||
|
directly, anywhere between and beyond the named steps. The time under your
|
||||||
|
fingers stays put as it zooms, so you keep your place in the day. A pinched
|
||||||
|
height is remembered and appears as **Custom** in the Hour height setting, so
|
||||||
|
tapping a named step there takes you back to it ([#56]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The date in the top bar is now the way to jump: tapping the month, week or day
|
||||||
|
title opens the same date picker the sidebar's **Jump to date** offers, seeded
|
||||||
|
on whatever the bar is naming. A drop-down caret marks it as tappable. The
|
||||||
|
sidebar entry stays where it is ([#57]).
|
||||||
|
- A reminder in the status bar is now Calendula's own mark — the calendar with
|
||||||
|
the bloom — instead of the generic calendar glyph it shared with the system's
|
||||||
|
date surfaces and every other calendar app. The status bar draws that icon as
|
||||||
|
a plain silhouette, so its shape is the only thing that could tell them apart
|
||||||
|
([#83]).
|
||||||
|
- Event blocks now only draw text they can draw whole. One too short for a full
|
||||||
|
line shows no title rather than a sliced one, a block that cannot fit both its
|
||||||
|
title and its time keeps the title, and a block too narrow to hold more than a
|
||||||
|
syllable stays on one ellipsised line instead of stacking letters down the
|
||||||
|
block. Tapping and the spoken description are unchanged ([#56]).
|
||||||
|
- Calendar colours are reworked so text on an event is always readable. They
|
||||||
|
were shaped by a brightness setting that does not match what the eye sees, so
|
||||||
|
whether a block got dark or light text depended on which hue you happened to
|
||||||
|
pick — an orange calendar took dark text while a red one beside it took light
|
||||||
|
— and colours landing in between were hard to read either way. Each colour now
|
||||||
|
keeps its hue and is moved clear of that middle: most become deep blocks with
|
||||||
|
light text, while naturally pale colours such as yellow stay pale and take
|
||||||
|
dark text, rather than being forced into a muddy brown. Dots, stripes and
|
||||||
|
icons are tuned separately from blocks, so they stay visible against the
|
||||||
|
background instead of sharing a colour meant to sit behind text. The setting
|
||||||
|
is now called **Harmonise calendar colours**; turning it off still shows the
|
||||||
|
raw colours from your calendar source ([#21], [#36]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The back gesture closes the sidebar instead of the app. With the drawer open,
|
||||||
|
swiping back left Calendula altogether rather than putting the sidebar away
|
||||||
|
([#114]).
|
||||||
|
- The sidebar lines up. "Calendula", "View" and "Calendars" now share the left
|
||||||
|
edge of the rows under them, and the view, jump-to-date, Settings and calendar
|
||||||
|
rows sit on one vertical axis instead of each icon finding its own — the same
|
||||||
|
alignment the Settings screens use ([#114]).
|
||||||
|
- The status- and navigation-bar icons follow Calendula's own light/dark choice.
|
||||||
|
Setting the app dark while the system stayed light — or the other way round —
|
||||||
|
left the clock and battery drawn for the system's theme, so they could sit
|
||||||
|
near-invisible against the app's own bar ([#70]).
|
||||||
|
|
||||||
## [2.17.1] — 2026-07-30
|
## [2.17.1] — 2026-07-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1249,7 +1470,26 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
||||||
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
||||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||||
|
[#83]: https://codeberg.org/jlmakiola/calendula/issues/83
|
||||||
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
||||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||||
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||||
|
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54
|
||||||
|
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
|
||||||
|
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
||||||
|
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
|
||||||
|
[#148]: https://codeberg.org/jlmakiola/calendula/issues/148
|
||||||
|
[#68]: https://codeberg.org/jlmakiola/calendula/issues/68
|
||||||
|
[#80]: https://codeberg.org/jlmakiola/calendula/issues/80
|
||||||
|
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||||
|
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||||
|
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||||
|
[#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
|
||||||
|
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228
|
||||||
|
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -18,6 +18,8 @@ Reads, writes, and reminds — on top of the system calendar, with zero network
|
|||||||
<p>
|
<p>
|
||||||
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
<a href="https://f-droid.org/packages/de.jeanlucmakiola.calendula/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="56"></a>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -86,7 +88,7 @@ Pick whichever channel you already use — they all install the same app:
|
|||||||
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
| [Official F-Droid](#f-droid-recommended) | On F-Droid's build schedule | Recommended; no extra setup |
|
||||||
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
|
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo-fastest-updates) | Minutes after a release | Fastest; needs the repo added once |
|
||||||
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
|
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Per release | Plain APK download, or automated by Obtainium |
|
||||||
| [Google Play](#google-play-coming-soon) | — | Coming soon |
|
| [Google Play](#google-play) | Per release | Signed with Google's key — see below |
|
||||||
| [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)
|
||||||
@@ -131,18 +133,14 @@ or do it by hand: *Add App* → paste `https://codeberg.org/jlmakiola/calendula`
|
|||||||
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
|
→ *Add*. Either way, Obtainium tracks the releases and prompts you when a new
|
||||||
one appears.
|
one appears.
|
||||||
|
|
||||||
### Google Play (coming soon)
|
### Google Play
|
||||||
|
|
||||||
Calendula is on its way to Google Play as an additional channel. It isn't live
|
Calendula is live on Google Play:
|
||||||
yet — this section gets a link once it is. Play builds will be signed with
|
**[play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula](https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula)**.
|
||||||
Google's key rather than mine, so switching between Play and any other channel
|
|
||||||
will require an uninstall.
|
|
||||||
|
|
||||||
> **Testers wanted.** Play requires a round of closed testing before the app can
|
Play builds are signed with Google's key rather than mine, so switching between
|
||||||
> go public, and I'm still looking for testers. If you'd like to help, email
|
Play and any other channel requires an uninstall (and with it, a fresh start for
|
||||||
> **[business@jeanlucmakiola.de](mailto:business@jeanlucmakiola.de)** with the
|
app settings — your events live in the system calendar and are unaffected).
|
||||||
> 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
|
||||||
|
|
||||||
@@ -151,7 +149,7 @@ The build is a plain Gradle build with no proprietary dependencies — see
|
|||||||
|
|
||||||
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
|
<sub>Official F-Droid, the self-hosted repo, and the Codeberg releases all share
|
||||||
the same signing key, so you can switch freely between them without
|
the same signing key, so you can switch freely between them without
|
||||||
reinstalling.</sub>
|
reinstalling. Google Play is the exception — see above.</sub>
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 21701
|
versionCode = 21903
|
||||||
versionName = "2.17.1"
|
versionName = "2.19.3"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
6
app/proguard-rules.pro
vendored
6
app/proguard-rules.pro
vendored
@@ -50,3 +50,9 @@
|
|||||||
# 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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
@@ -330,9 +330,13 @@
|
|||||||
</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).
|
||||||
and the system broadcasts the date/time ones at midnight / clock
|
The day boundary arrives as the app's own ROLLOVER alarm (#228), by
|
||||||
changes so "today" highlighting rolls over. -->
|
explicit PendingIntent, so it needs no filter here; DATE_CHANGED is
|
||||||
|
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">
|
||||||
@@ -346,6 +350,8 @@
|
|||||||
<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,6 +10,7 @@ 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
|
||||||
@@ -44,6 +45,19 @@ class CalendulaApp : Application() {
|
|||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
reconcileCalendarVisibility()
|
reconcileCalendarVisibility()
|
||||||
startReminderDelivery()
|
startReminderDelivery()
|
||||||
|
reconcileWidgetRollover()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-arm the widgets' midnight rollover from whatever is actually placed
|
||||||
|
* (#228). Idempotent, and it covers what no broadcast reaches — an alarm
|
||||||
|
* dropped by a force-stop is armed again the next time the app is opened.
|
||||||
|
* Off the main thread: a handful of binder calls on every process start.
|
||||||
|
*/
|
||||||
|
private fun reconcileWidgetRollover() {
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
WidgetRolloverScheduler.sync(this@CalendulaApp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.calendula
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
@@ -12,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||||||
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.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -29,11 +32,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||||
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.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||||
|
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
@@ -50,9 +56,17 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** A prefilled create form from an external launch, with the source it came from. */
|
||||||
|
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
// Which of light/dark the system bars are drawn for. The styles installed
|
||||||
|
// in onCreate read this field live, so androidx's config-change replay
|
||||||
|
// picks up the in-app override instead of the night resource qualifier.
|
||||||
|
private var systemBarsDark = false
|
||||||
|
|
||||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||||
// tap into the running activity; CalendarHost consumes and clears it.
|
// tap into the running activity; CalendarHost consumes and clears it.
|
||||||
@@ -69,7 +83,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||||
// app/widget asking us to create an event, issue #30). Consumed once by
|
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||||
// CalendarHost, which opens it in the create form for review.
|
// CalendarHost, which opens it in the create form for review.
|
||||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
||||||
|
|
||||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||||
@@ -96,11 +110,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
enableEdgeToEdge()
|
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||||
|
Configuration.UI_MODE_NIGHT_YES
|
||||||
|
applyEdgeToEdge()
|
||||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||||
requestedNav = intent.navRequestOrNull()
|
requestedNav = intent.navRequestOrNull()
|
||||||
requestedImportUri = intent.importUriOrNull()
|
requestedImportUri = intent.importUriOrNull()
|
||||||
requestedInsertForm = intent.insertFormOrNull()
|
requestedInsert = intent.insertRequestOrNull()
|
||||||
requestedEditKey = intent.editEventKeyOrNull()
|
requestedEditKey = intent.editEventKeyOrNull()
|
||||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
setContent {
|
setContent {
|
||||||
@@ -113,6 +129,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
ThemeMode.LIGHT -> false
|
ThemeMode.LIGHT -> false
|
||||||
ThemeMode.DARK -> true
|
ThemeMode.DARK -> true
|
||||||
}
|
}
|
||||||
|
// onCreate can only see the night resource qualifier, not the in-app
|
||||||
|
// override — re-apply from the resolved theme so the bar icons follow
|
||||||
|
// the app's light/dark choice.
|
||||||
|
DisposableEffect(darkTheme) {
|
||||||
|
systemBarsDark = darkTheme
|
||||||
|
applyEdgeToEdge()
|
||||||
|
onDispose {}
|
||||||
|
}
|
||||||
// The app-wide clock convention: the time-format preference resolved
|
// The app-wide clock convention: the time-format preference resolved
|
||||||
// against the device's 24-hour system setting, provided once here so
|
// against the device's 24-hour system setting, provided once here so
|
||||||
// every time label reads it via LocalUse24HourFormat.
|
// every time label reads it via LocalUse24HourFormat.
|
||||||
@@ -125,6 +149,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// re-import stamp AppFontSettings carries, so replacing the file
|
// re-import stamp AppFontSettings carries, so replacing the file
|
||||||
// behind an active "custom" token still refreshes — changes;
|
// behind an active "custom" token still refreshes — changes;
|
||||||
// "system for both" returns the default scale untouched.
|
// "system for both" returns the default scale untouched.
|
||||||
|
// The timeline scale plus the pinch in flight over it (#56). Held
|
||||||
|
// here, above the calendar views, so a zoom survives paging between
|
||||||
|
// weeks and switching between the week and day view.
|
||||||
|
val timelineZoom = rememberTimelineZoom(
|
||||||
|
stored = settings.timelineScale,
|
||||||
|
onPersist = settingsViewModel::setTimelineScale,
|
||||||
|
)
|
||||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
val typography = remember(fonts, context) {
|
val typography = remember(fonts, context) {
|
||||||
calendulaTypography(
|
calendulaTypography(
|
||||||
@@ -141,6 +172,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalUse24HourFormat provides use24Hour,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
|
LocalTimelineZoom provides timelineZoom,
|
||||||
LocalSoftenColors provides settings.softenColors,
|
LocalSoftenColors provides settings.softenColors,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
RootScreen(
|
||||||
@@ -151,8 +183,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
onWidgetNavConsumed = { requestedNav = null },
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
requestedImportUri = requestedImportUri,
|
requestedImportUri = requestedImportUri,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onImportConsumed = { requestedImportUri = null },
|
||||||
requestedInsertForm = requestedInsertForm,
|
requestedInsertForm = requestedInsert?.form,
|
||||||
onInsertConsumed = { requestedInsertForm = null },
|
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
|
||||||
|
onInsertConsumed = { requestedInsert = null },
|
||||||
requestedEditKey = requestedEditKey,
|
requestedEditKey = requestedEditKey,
|
||||||
onEditKeyConsumed = { requestedEditKey = null },
|
onEditKeyConsumed = { requestedEditKey = null },
|
||||||
)
|
)
|
||||||
@@ -181,6 +214,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the transparent edge-to-edge bars for the current [systemBarsDark].
|
||||||
|
* Both scrims are transparent because API 29+ enforces its own contrast and
|
||||||
|
* ignores them anyway.
|
||||||
|
*/
|
||||||
|
private fun applyEdgeToEdge() {
|
||||||
|
val transparent = android.graphics.Color.TRANSPARENT
|
||||||
|
enableEdgeToEdge(
|
||||||
|
statusBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||||
|
navigationBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||||
@@ -192,7 +238,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
intent.insertRequestOrNull()?.let { requestedInsert = it }
|
||||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,9 +265,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||||
* [buildInsertEventForm].
|
* [buildInsertEventForm]. An intent that names no end time leaves the length
|
||||||
|
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
|
||||||
*/
|
*/
|
||||||
private fun Intent.insertFormOrNull(): EventForm? {
|
private fun Intent.insertRequestOrNull(): InsertRequest? {
|
||||||
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
||||||
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
||||||
// as is any plain ACTION_INSERT.
|
// as is any plain ACTION_INSERT.
|
||||||
@@ -229,9 +276,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
||||||
editEventKeyOrNull() == null)
|
editEventKeyOrNull() == null)
|
||||||
if (!isCreate) return null
|
if (!isCreate) return null
|
||||||
return buildInsertEventForm(
|
val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME)
|
||||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME)
|
||||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
val form = buildInsertEventForm(
|
||||||
|
beginMillis = beginMillis,
|
||||||
|
endMillis = endMillis,
|
||||||
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||||
title = getStringExtra(CalendarContract.Events.TITLE),
|
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||||
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||||
@@ -240,6 +289,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
zone = TimeZone.currentSystemDefault(),
|
zone = TimeZone.currentSystemDefault(),
|
||||||
now = Clock.System.now(),
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
|
// An end the intent didn't name — or one [buildInsertEventForm] drops for
|
||||||
|
// landing before the start — leaves the length to the setting.
|
||||||
|
val namesEnd = beginMillis != null && endMillis != null && endMillis >= beginMillis
|
||||||
|
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A Long extra's value, or null when the extra is absent. */
|
/** A Long extra's value, or null when the extra is absent. */
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
|
|||||||
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
@@ -62,12 +64,14 @@ interface CalendarDataSource {
|
|||||||
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
fun eventDetail(eventId: Long, allDayReminderTimeMinutes: Int): EventDetail?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master/one-off events whose title, description or location contains
|
* Master/one-off events that may match [query], across all calendars: every
|
||||||
* [query] (case-insensitive), across all calendars, newest first. Reads the
|
* whitespace-separated token has to appear in the title, description or
|
||||||
* Events table directly so the search is unbounded in time; exception rows
|
* location. Reads the Events table directly so the search is unbounded in
|
||||||
* are excluded (see [SearchProjection]). [query] is assumed non-blank.
|
* time; exception rows are excluded (see [SearchProjection]). A deliberate
|
||||||
|
* superset — [EventSearch] makes the final call and orders the hits.
|
||||||
|
* [query] is assumed non-blank.
|
||||||
*/
|
*/
|
||||||
fun searchEvents(query: String): List<EventInstance>
|
fun searchEvents(query: String): List<SearchCandidate>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette the calendar's account publishes
|
* The event-colour palette the calendar's account publishes
|
||||||
@@ -228,10 +232,15 @@ interface CalendarDataSource {
|
|||||||
): Long
|
): Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a single occurrence of a recurring event by inserting a
|
* Change a single occurrence of a recurring event at [beginMillis] (the
|
||||||
* modified-occurrence exception at [beginMillis] (the occurrence's
|
* occurrence's `Instances.BEGIN`) to [form]'s values; returns the
|
||||||
* `Instances.BEGIN`) carrying [form]'s values; returns the exception
|
* `Events._ID` of the row now holding them.
|
||||||
* 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]).
|
||||||
|
* [allDayReminderTimeMinutes]: see [insertEvent].
|
||||||
*/
|
*/
|
||||||
fun updateOccurrence(
|
fun updateOccurrence(
|
||||||
eventId: Long,
|
eventId: Long,
|
||||||
@@ -585,20 +594,23 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchEvents(query: String): List<EventInstance> {
|
override fun searchEvents(query: String): List<SearchCandidate> {
|
||||||
ensureObserversRegistered()
|
ensureObserversRegistered()
|
||||||
val trimmed = query.trim()
|
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
|
||||||
if (trimmed.isEmpty()) return emptyList()
|
if (tokens.isEmpty()) return emptyList()
|
||||||
// Escape the SQL LIKE wildcards so a literal % or _ in the query matches
|
// Only a pre-filter: EventSearch re-checks every token, including the
|
||||||
// itself instead of acting as a wildcard.
|
// ones the cap above dropped.
|
||||||
val escaped = trimmed
|
val patterns = tokens.map { likePatterns(it) }
|
||||||
.replace("\\", "\\\\")
|
val match = patterns.joinToString(" AND ") { variants ->
|
||||||
.replace("%", "\\%")
|
variants.joinToString(" OR ", prefix = "(", postfix = ")") {
|
||||||
.replace("_", "\\_")
|
"${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
||||||
val like = "%$escaped%"
|
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
||||||
val match = "${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
|
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
||||||
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
|
}
|
||||||
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\'"
|
}
|
||||||
|
val args = patterns
|
||||||
|
.flatMap { variants -> variants.flatMap { pattern -> List(3) { pattern } } }
|
||||||
|
.toTypedArray()
|
||||||
val selection = "($match) AND " +
|
val selection = "($match) AND " +
|
||||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||||
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
"${CalendarContract.Events.ORIGINAL_ID} IS NULL"
|
||||||
@@ -606,19 +618,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
CalendarContract.Events.CONTENT_URI,
|
CalendarContract.Events.CONTENT_URI,
|
||||||
SearchProjection.COLUMNS,
|
SearchProjection.COLUMNS,
|
||||||
selection,
|
selection,
|
||||||
arrayOf(like, like, like),
|
args,
|
||||||
CalendarContract.Events.DTSTART + " DESC",
|
CalendarContract.Events.DTSTART + " DESC",
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
val reader = CursorColumnReader(c)
|
val reader = CursorColumnReader(c)
|
||||||
val out = ArrayList<EventInstance>(c.count)
|
val out = ArrayList<SearchCandidate>(c.count)
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
|
val description = reader.getString(SearchProjection.IDX_DESCRIPTION)
|
||||||
val base = reader.toSearchResult() ?: continue
|
val base = reader.toSearchResult() ?: continue
|
||||||
// A recurring master's DTSTART is the series start; show its
|
// A recurring master's DTSTART is the series start; date the row
|
||||||
// nearest occurrence instead so the date is the one the user
|
// by its nearest occurrence instead.
|
||||||
// actually cares about (and sorting reflects it).
|
val event = if (base.isRecurring) {
|
||||||
val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
|
||||||
!reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty()
|
|
||||||
out += if (recurring) {
|
|
||||||
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
nearestOccurrenceMillis(base.eventId)?.let { (begin, end) ->
|
||||||
base.copy(
|
base.copy(
|
||||||
start = begin.toKotlinInstantFromEpochMillis(),
|
start = begin.toKotlinInstantFromEpochMillis(),
|
||||||
@@ -628,11 +638,44 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
base
|
base
|
||||||
}
|
}
|
||||||
|
// The raw title, so matching can't catch on the placeholder an
|
||||||
|
// untitled event is drawn with.
|
||||||
|
out += SearchCandidate(
|
||||||
|
event = event,
|
||||||
|
description = description,
|
||||||
|
title = reader.getString(SearchProjection.IDX_TITLE),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One token as the `LIKE` patterns to OR together. SQLite folds case for
|
||||||
|
* ASCII only, so a token with cased non-ASCII letters is also queried lower-,
|
||||||
|
* upper- and title-cased, which is how "ärzte" reaches "Ärzte". Wildcarding
|
||||||
|
* those letters away instead would make "москва" `%______%` — a full scan.
|
||||||
|
*/
|
||||||
|
private fun likePatterns(token: String): List<String> {
|
||||||
|
if (token.none { it.code > 127 && (it.isUpperCase() || it.isLowerCase()) }) {
|
||||||
|
return listOf(likePattern(token))
|
||||||
|
}
|
||||||
|
val lower = token.lowercase()
|
||||||
|
return listOf(token, lower, token.uppercase(), lower.replaceFirstChar(Char::uppercaseChar))
|
||||||
|
.distinct()
|
||||||
|
.map(::likePattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun likePattern(token: String): String {
|
||||||
|
val sb = StringBuilder("%")
|
||||||
|
for (c in token) {
|
||||||
|
// A literal wildcard from the query matches itself.
|
||||||
|
if (c == '%' || c == '_' || c == '\\') sb.append('\\')
|
||||||
|
sb.append(c)
|
||||||
|
}
|
||||||
|
return sb.append('%').toString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
* The occurrence of [eventId] nearest to now: the soonest upcoming one
|
||||||
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
* within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one
|
||||||
@@ -1156,6 +1199,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
form: EventForm,
|
form: EventForm,
|
||||||
allDayReminderTimeMinutes: Int,
|
allDayReminderTimeMinutes: Int,
|
||||||
): Long {
|
): Long {
|
||||||
|
val row = querySeriesRow(eventId)
|
||||||
|
// Stricter than deleteOccurrence's bare _sync_id check: EXDATE only means
|
||||||
|
// something on a row that recurs, so a non-recurring one keeps the
|
||||||
|
// exception path rather than getting a recurrence set written onto it.
|
||||||
|
if (row.syncId == null && !row.rrule.isNullOrBlank()) {
|
||||||
|
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,
|
||||||
@@ -1174,6 +1224,89 @@ 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, isAllDay = row.allDay != 0)) {
|
||||||
|
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,
|
||||||
|
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,
|
||||||
@@ -1593,5 +1726,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
* next fires beyond it falls back to its series-start date.
|
* next fires beyond it falls back to its series-start date.
|
||||||
*/
|
*/
|
||||||
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens the SQL pre-filter is built from. Bounds the statement for a
|
||||||
|
* pasted paragraph; the ranker still requires every token, so a capped
|
||||||
|
* search returns fewer rows to check, never more hits.
|
||||||
|
*/
|
||||||
|
const val MAX_SEARCH_TOKENS = 8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
@@ -17,11 +18,11 @@ interface CalendarRepository {
|
|||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
suspend fun eventDetail(eventId: Long): EventDetail
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Events whose title, description or location contains [query], with hidden
|
* Candidate matches for [query] with hidden calendars removed; empty when
|
||||||
* calendars removed and newest first. Empty when [query] is blank. Searches
|
* [query] is blank. Searches the whole history/future and leaves matching
|
||||||
* the whole history/future (see [CalendarDataSource.searchEvents]).
|
* and ranking to [de.jeanlucmakiola.calendula.domain.EventSearch].
|
||||||
*/
|
*/
|
||||||
suspend fun searchEvents(query: String): List<EventInstance>
|
suspend fun searchEvents(query: String): List<SearchCandidate>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette a calendar's account publishes; empty when it
|
* The event-colour palette a calendar's account publishes; empty when it
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchCandidate
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -162,13 +163,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
?: throw NoSuchEventException(eventId)
|
?: throw NoSuchEventException(eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchEvents(query: String): List<EventInstance> = withContext(io) {
|
override suspend fun searchEvents(query: String): List<SearchCandidate> = withContext(io) {
|
||||||
if (query.isBlank()) return@withContext emptyList()
|
if (query.isBlank()) return@withContext emptyList()
|
||||||
val excluded = prefs.hiddenCalendarIds.first() +
|
val excluded = prefs.hiddenCalendarIds.first() +
|
||||||
prefs.pendingDisabledCalendarIds.first() +
|
prefs.pendingDisabledCalendarIds.first() +
|
||||||
invisibleCalendarIds()
|
invisibleCalendarIds()
|
||||||
dataSource.searchEvents(query)
|
dataSource.searchEvents(query)
|
||||||
.let { if (excluded.isEmpty()) it else it.filterNot { e -> e.calendarId in excluded } }
|
.let { if (excluded.isEmpty()) it else it.filterNot { c -> c.event.calendarId in excluded } }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
override suspend fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||||
eventColor = eventColor,
|
eventColor = eventColor,
|
||||||
eventColorKey = eventColorKey,
|
eventColorKey = eventColorKey,
|
||||||
|
isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,21 @@ 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`
|
||||||
@@ -435,6 +450,23 @@ internal fun buildOccurrenceExdateValues(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
): Boolean {
|
||||||
|
val stamp = formatExdateStamp(occurrenceMillis, isAllDay)
|
||||||
|
return existingExdate?.split(',')?.any { it.trim() == stamp } == true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -38,5 +39,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
|
|||||||
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(InstanceProjection.IDX_LOCATION),
|
location = getString(InstanceProjection.IDX_LOCATION),
|
||||||
|
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ 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
|
||||||
@@ -64,6 +65,7 @@ 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 {
|
||||||
@@ -89,6 +91,9 @@ internal object EventDetailProjection {
|
|||||||
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||||
// needs it to render a series opened without a named occurrence.
|
// needs it to render a series opened without a named occurrence.
|
||||||
CalendarContract.Events.DURATION,
|
CalendarContract.Events.DURATION,
|
||||||
|
// Non-null on a modified-occurrence exception row; "no RRULE" alone
|
||||||
|
// can't tell an exception from a master (#68).
|
||||||
|
CalendarContract.Events.ORIGINAL_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_EVENT_ID = 0
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -110,6 +115,7 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||||
const val IDX_EVENT_COLOR_KEY = 17
|
const val IDX_EVENT_COLOR_KEY = 17
|
||||||
const val IDX_DURATION = 18
|
const val IDX_DURATION = 18
|
||||||
|
const val IDX_ORIGINAL_ID = 19
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,6 +182,9 @@ internal object SearchProjection {
|
|||||||
// display its nearest occurrence, not the series-start DTSTART.
|
// display its nearest occurrence, not the series-start DTSTART.
|
||||||
CalendarContract.Events.RRULE,
|
CalendarContract.Events.RRULE,
|
||||||
CalendarContract.Events.RDATE,
|
CalendarContract.Events.RDATE,
|
||||||
|
// Excerpted, not just filtered on: a hit has to show what it matched.
|
||||||
|
CalendarContract.Events.DESCRIPTION,
|
||||||
|
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_ID = 0
|
const val IDX_ID = 0
|
||||||
@@ -190,6 +199,8 @@ internal object SearchProjection {
|
|||||||
const val IDX_LOCATION = 9
|
const val IDX_LOCATION = 9
|
||||||
const val IDX_RRULE = 10
|
const val IDX_RRULE = 10
|
||||||
const val IDX_RDATE = 11
|
const val IDX_RDATE = 11
|
||||||
|
const val IDX_DESCRIPTION = 12
|
||||||
|
const val IDX_SELF_ATTENDEE_STATUS = 13
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
@@ -40,5 +41,9 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
|
|||||||
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
isAllDay = getInt(SearchProjection.IDX_ALL_DAY) != 0,
|
||||||
color = color,
|
color = color,
|
||||||
location = getString(SearchProjection.IDX_LOCATION),
|
location = getString(SearchProjection.IDX_LOCATION),
|
||||||
|
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
|
||||||
|
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
|
||||||
|
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ 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.TimelineScale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
@@ -270,6 +273,19 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
|
||||||
|
* [TimelineScale.Regular] — the historical 56dp scale. Holds either a preset
|
||||||
|
* or the height a pinch on the timeline settled at.
|
||||||
|
*/
|
||||||
|
val timelineScale: Flow<TimelineScale> = store.data.map { prefs ->
|
||||||
|
parseTimelineScale(prefs[TIMELINE_SCALE_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTimelineScale(scale: TimelineScale) {
|
||||||
|
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 "+"
|
||||||
@@ -285,6 +301,19 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
store.edit { it[TODAY_BUTTON_IN_TOOLBAR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether events can be dragged to another slot in the calendar views (#68,
|
||||||
|
* #173). Defaults to ON. Off means no block registers a drag gesture at all;
|
||||||
|
* rescheduling then goes through the edit form.
|
||||||
|
*/
|
||||||
|
val dragToReschedule: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[DRAG_TO_RESCHEDULE_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDragToReschedule(enabled: Boolean) {
|
||||||
|
store.edit { it[DRAG_TO_RESCHEDULE_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||||
@@ -457,6 +486,46 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a new **timed** event lasts, in minutes (#54). Defaults to
|
||||||
|
* [DEFAULT_EVENT_DURATION] — the historical fixed hour. Per-calendar
|
||||||
|
* overrides in [perCalendarEventDuration] take precedence; all-day events are
|
||||||
|
* date-anchored and ignore it. Resolve with [resolveDefaultEventDuration].
|
||||||
|
*/
|
||||||
|
val defaultEventDurationMinutes: Flow<Int> = store.data.map { prefs ->
|
||||||
|
(prefs[DEFAULT_EVENT_DURATION_KEY] ?: DEFAULT_EVENT_DURATION)
|
||||||
|
.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDefaultEventDurationMinutes(minutes: Int) {
|
||||||
|
store.edit {
|
||||||
|
it[DEFAULT_EVENT_DURATION_KEY] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-calendar overrides of [defaultEventDurationMinutes], keyed by calendar
|
||||||
|
* id: a calendar **present** in the map gives its new events that length, one
|
||||||
|
* **absent** inherits the global default (there is no "no duration", so this
|
||||||
|
* needs no `none` sentinel). Serialised as `id=minutes;id=minutes`.
|
||||||
|
*/
|
||||||
|
val perCalendarEventDuration: Flow<Map<Long, Int>> = store.data.map { prefs ->
|
||||||
|
parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [minutes] null drops the override, so the calendar inherits the global default. */
|
||||||
|
suspend fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
val current = parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]).toMutableMap()
|
||||||
|
if (minutes == null) {
|
||||||
|
current.remove(calendarId)
|
||||||
|
} else {
|
||||||
|
current[calendarId] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||||
|
}
|
||||||
|
prefs[CALENDAR_EVENT_DURATION_KEY] = current.toStoredDurations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
* for users whose only calendar app this is, reminders are essential; the
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
@@ -494,8 +563,87 @@ class SettingsPrefs @Inject constructor(
|
|||||||
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
prefs[REMINDER_ONBOARDING_KEY] ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setReminderOnboardingDone() {
|
/** Cleared, not just set: the wizard's back button re-asks a step (#163). */
|
||||||
store.edit { it[REMINDER_ONBOARDING_KEY] = true }
|
suspend fun setReminderOnboardingDone(done: Boolean = true) {
|
||||||
|
store.edit { it[REMINDER_ONBOARDING_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this install is a fresh one still owing the wizard's optional
|
||||||
|
* steps (#163). Armed on any launch that holds the calendar permission and
|
||||||
|
* has not finished the reminder step, and cleared again by
|
||||||
|
* [finishOnboardingWizard], so an existing user who revokes and re-grants
|
||||||
|
* the permission isn't re-onboarded.
|
||||||
|
*/
|
||||||
|
val onboardingWizardArmed: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_WIZARD_ARMED_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun armOnboardingWizard() {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (prefs[REMINDER_ONBOARDING_KEY] != true) prefs[ONBOARDING_WIZARD_ARMED_KEY] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's backup step has been answered (or skipped). */
|
||||||
|
val onboardingBackupDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_BACKUP_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingBackupDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_BACKUP_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's default-view step has been answered (or skipped). */
|
||||||
|
val onboardingViewDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_VIEW_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingViewDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_VIEW_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's month-style step has been answered (or skipped). */
|
||||||
|
val onboardingMonthStyleDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_MONTH_STYLE_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingMonthStyleDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_MONTH_STYLE_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the wizard's calendar-visibility notice has been read. Separate
|
||||||
|
* from [CalendarPrefs.visibilityNoticePending], which says whether this
|
||||||
|
* install has anything to announce at all.
|
||||||
|
*/
|
||||||
|
val onboardingVisibilityDone: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_VISIBILITY_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingVisibilityDone(done: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_VISIBILITY_KEY] = done }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the wizard's closing screen has been seen. */
|
||||||
|
val onboardingDoneShown: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[ONBOARDING_DONE_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setOnboardingDoneShown(shown: Boolean = true) {
|
||||||
|
store.edit { it[ONBOARDING_DONE_KEY] = shown }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the wizard for good: the closing screen has been seen and the
|
||||||
|
* install stops counting as fresh, so a later revoke-and-re-grant is only
|
||||||
|
* the permission screen. One edit, so the plan can't see it half-closed.
|
||||||
|
*/
|
||||||
|
suspend fun finishOnboardingWizard() {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[ONBOARDING_DONE_KEY] = true
|
||||||
|
prefs[ONBOARDING_WIZARD_ARMED_KEY] = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -808,14 +956,33 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||||
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||||
|
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
||||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||||
|
internal val DRAG_TO_RESCHEDULE_KEY = booleanPreferencesKey("drag_to_reschedule")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||||
|
internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes")
|
||||||
|
internal val CALENDAR_EVENT_DURATION_KEY =
|
||||||
|
stringPreferencesKey("per_calendar_event_duration")
|
||||||
|
|
||||||
|
/** A new timed event's length until the user changes it: one hour. */
|
||||||
|
const val DEFAULT_EVENT_DURATION = 60
|
||||||
|
internal const val MIN_EVENT_DURATION = 1
|
||||||
|
/** A day — past that the form is describing a multi-day event, not a default. */
|
||||||
|
internal const val MAX_EVENT_DURATION = 1_440
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
|
internal val ONBOARDING_WIZARD_ARMED_KEY = booleanPreferencesKey("onboarding_wizard_armed")
|
||||||
|
internal val ONBOARDING_BACKUP_KEY = booleanPreferencesKey("onboarding_backup_done")
|
||||||
|
internal val ONBOARDING_VIEW_KEY = booleanPreferencesKey("onboarding_view_done")
|
||||||
|
internal val ONBOARDING_MONTH_STYLE_KEY =
|
||||||
|
booleanPreferencesKey("onboarding_month_style_done")
|
||||||
|
internal val ONBOARDING_VISIBILITY_KEY =
|
||||||
|
booleanPreferencesKey("onboarding_visibility_done")
|
||||||
|
internal val ONBOARDING_DONE_KEY = booleanPreferencesKey("onboarding_done_shown")
|
||||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
booleanPreferencesKey("allow_color_unsupported_calendars")
|
booleanPreferencesKey("allow_color_unsupported_calendars")
|
||||||
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
internal val DEFAULT_REMINDER_KEY = stringPreferencesKey("default_reminder_minutes")
|
||||||
@@ -905,6 +1072,17 @@ fun resolveDefaultReminder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The length a new timed event on [calendarId] opens with: that calendar's
|
||||||
|
* override if it has one, otherwise the [global] default. Pure so it can be
|
||||||
|
* unit-tested.
|
||||||
|
*/
|
||||||
|
fun resolveDefaultEventDuration(
|
||||||
|
global: Int,
|
||||||
|
overrides: Map<Long, Int>,
|
||||||
|
calendarId: Long?,
|
||||||
|
): Int = calendarId?.let { overrides[it] } ?: global
|
||||||
|
|
||||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||||
private const val WEEK_START_AUTO = "AUTO"
|
private const val WEEK_START_AUTO = "AUTO"
|
||||||
|
|
||||||
@@ -952,6 +1130,27 @@ private fun String?.toReminderList(): List<Int> = when {
|
|||||||
private fun List<Int>.toStoredReminders(): String =
|
private fun List<Int>.toStoredReminders(): String =
|
||||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the per-calendar duration map (`id=minutes` entries joined by `;`).
|
||||||
|
* Malformed entries and out-of-range lengths are dropped, so a garbled value
|
||||||
|
* degrades to "inherits the global default" instead of throwing.
|
||||||
|
*/
|
||||||
|
private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
|
||||||
|
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
||||||
|
val parts = entry.split(KEY_VALUE_SEP)
|
||||||
|
if (parts.size != 2) return@mapNotNull null
|
||||||
|
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||||
|
val minutes = parts[1].trim().toIntOrNull()
|
||||||
|
?.takeIf { it in SettingsPrefs.MIN_EVENT_DURATION..SettingsPrefs.MAX_EVENT_DURATION }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
calendarId to minutes
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
private fun Map<Long, Int>.toStoredDurations(): String =
|
||||||
|
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
|
||||||
|
"$id$KEY_VALUE_SEP$minutes"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -44,9 +44,16 @@ 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.Events.STATUS_CANCELED})"
|
"${CalendarContract.Instances.STATUS} != " +
|
||||||
|
"${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 ->
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ val CalendarSource.hasVisibilitySwitch: Boolean
|
|||||||
val CalendarSource.isEventTarget: Boolean
|
val CalendarSource.isEventTarget: Boolean
|
||||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this calendar's events may have their times rewritten by a drag (#68).
|
||||||
|
* Deliberately not [isEventTarget]: a managed event stays editable (reminders,
|
||||||
|
* notes) yet must never move, since the next contacts sync would put it back.
|
||||||
|
*/
|
||||||
|
val CalendarSource.allowsEventMove: Boolean
|
||||||
|
get() = canModifyContents && !isManaged
|
||||||
|
|
||||||
/** Every state worth naming on this calendar's row, in reading order. */
|
/** Every state worth naming on this calendar's row, in reading order. */
|
||||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
import kotlin.math.abs
|
import de.jeanlucmakiola.calendula.domain.color.Oklch
|
||||||
import kotlin.math.atan2
|
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||||
import kotlin.math.cbrt
|
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||||
import kotlin.math.hypot
|
|
||||||
import kotlin.math.pow
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curates an account's published event palette for the colour picker.
|
* Curates an account's published event palette for the colour picker.
|
||||||
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
|
|||||||
* (#22).
|
* (#22).
|
||||||
*
|
*
|
||||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||||
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
* the raw provider value — and it gets that colour from the same [eventTone]
|
||||||
* it pins lightness to a constant and caps saturation, so the raw palette's
|
* the picker calls, rather than from a copy of its shaping kept in step by hand.
|
||||||
* lightness axis is invisible on screen. Two raw colours that look different —
|
* Because a harmonised container pins lightness, the raw palette's lightness
|
||||||
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
* axis is invisible on screen: two raw colours that look different — a navy and
|
||||||
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
* a mid blue — paint as one swatch, and every neutral (black, the grays, white)
|
||||||
* space, as before, left near-identical painted swatches and stranded the
|
* paints as the same grey. Judging distinctness in raw space, as before, left
|
||||||
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
* near-identical painted swatches and stranded the neutrals as a run of
|
||||||
|
* look-alike tints at the end of the grid.
|
||||||
*
|
*
|
||||||
* Three steps, all in painted space:
|
* Three steps, all in painted space:
|
||||||
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||||
* wins, deterministically) — this folds aliases, dark/light shades of a
|
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||||
* hue, and all the neutrals together.
|
* hue, and all the neutrals together.
|
||||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
|
||||||
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
* swatches — with lightness pinned, a grey source paints as plain grey, so
|
||||||
* then thinned to visually distinct colours: most vivid first, a colour is
|
* "has no hue left" is simply zero chroma — and are then thinned to visually
|
||||||
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
* distinct colours: most vivid first, a colour is kept only when at least
|
||||||
* colour already kept. Small palettes are already curated by their adapter
|
* [MIN_DISTANCE] away in Oklab from every colour already kept. Small
|
||||||
* and pass through whole.
|
* palettes are already curated by their adapter and pass through whole.
|
||||||
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||||
* with the wheel cut at its single widest empty gap so the one unavoidable
|
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||||
* seam lands in dead space and no hue family is torn across both ends.
|
* seam lands in dead space and no hue family is torn across both ends.
|
||||||
@@ -45,12 +42,12 @@ import kotlin.math.sqrt
|
|||||||
*/
|
*/
|
||||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||||
val painted = sortedBy { it.key }
|
val painted = sortedBy { it.key }
|
||||||
.distinctBy { pastelArgb(it.argb) }
|
.distinctBy { paintedArgb(it.argb) }
|
||||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
.map { it to oklchOf(paintedArgb(it.argb)) }
|
||||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||||
painted
|
painted
|
||||||
} else {
|
} else {
|
||||||
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
|
||||||
}
|
}
|
||||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||||
}
|
}
|
||||||
@@ -61,17 +58,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
|||||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||||
*/
|
*/
|
||||||
private fun orderAroundWheel(
|
private fun orderAroundWheel(
|
||||||
swatches: List<Pair<EventColorOption, Lab>>,
|
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||||
): List<Pair<EventColorOption, Lab>> {
|
): List<Pair<EventColorOption, Oklch>> {
|
||||||
if (swatches.size < 2) return swatches
|
if (swatches.size < 2) return swatches
|
||||||
val byHue = swatches.sortedWith(
|
val byHue = swatches.sortedWith(
|
||||||
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.chroma }),
|
||||||
)
|
)
|
||||||
// Split the wheel after the largest empty arc between neighbouring hues;
|
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||||
// the default is the wrap gap (last hue back round to the first), i.e. the
|
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||||
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||||
var cutAfter = byHue.lastIndex
|
var cutAfter = byHue.lastIndex
|
||||||
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
var widestGap = 360f - byHue.last().second.hue + byHue.first().second.hue
|
||||||
for (i in 0 until byHue.lastIndex) {
|
for (i in 0 until byHue.lastIndex) {
|
||||||
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||||
if (gap > widestGap) {
|
if (gap > widestGap) {
|
||||||
@@ -84,102 +81,32 @@ private fun orderAroundWheel(
|
|||||||
|
|
||||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||||
private fun thin(
|
private fun thin(
|
||||||
swatches: List<Pair<EventColorOption, Lab>>,
|
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||||
): List<Pair<EventColorOption, Lab>> {
|
): List<Pair<EventColorOption, Oklch>> {
|
||||||
val byVividness = swatches
|
val byVividness = swatches
|
||||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
.sortedWith(
|
||||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
|
||||||
|
.thenBy { it.first.key },
|
||||||
|
)
|
||||||
|
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
|
||||||
for (candidate in byVividness) {
|
for (candidate in byVividness) {
|
||||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
|
||||||
}
|
}
|
||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The colour the picker paints for [argb]; the light theme stands in as the
|
||||||
* The softening the colour picker paints over every swatch: keep the hue, scale
|
* reference, since a harmonised container differs only in lightness by theme
|
||||||
* and clamp saturation into a gentle band, and pin value to a constant so
|
* and curation compares hue and chroma. */
|
||||||
* nothing screams and everything reads on the surface. Value is fixed here so
|
private fun paintedArgb(argb: Int): Int =
|
||||||
* curation is theme-independent — only hue and saturation distinguish painted
|
eventTone(argb, dark = false, harmonise = true).container
|
||||||
* swatches.
|
|
||||||
*
|
|
||||||
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
|
|
||||||
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
|
|
||||||
* rather than theme-picked. Curation must reason about the colour the picker
|
|
||||||
* paints, so the two shapings have to agree: if floret's saturation band or
|
|
||||||
* curve changes, update this in step.
|
|
||||||
*/
|
|
||||||
fun pastelArgb(rawArgb: Int): Int {
|
|
||||||
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
|
||||||
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
|
||||||
val b = (rawArgb and 0xFF) / 255f
|
|
||||||
val max = maxOf(r, g, b)
|
|
||||||
val min = minOf(r, g, b)
|
|
||||||
val delta = max - min
|
|
||||||
val hue = when {
|
|
||||||
delta == 0f -> 0f
|
|
||||||
max == r -> 60f * (((g - b) / delta) % 6f)
|
|
||||||
max == g -> 60f * (((b - r) / delta) + 2f)
|
|
||||||
else -> 60f * (((r - g) / delta) + 4f)
|
|
||||||
}.let { if (it < 0f) it + 360f else it }
|
|
||||||
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
|
||||||
val s = sat.coerceIn(0.25f, 0.65f)
|
|
||||||
val v = PASTEL_VALUE
|
|
||||||
val c = v * s
|
|
||||||
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
|
||||||
val m = v - c
|
|
||||||
val (rr, gg, bb) = when {
|
|
||||||
hue < 60f -> Triple(c, x, 0f)
|
|
||||||
hue < 120f -> Triple(x, c, 0f)
|
|
||||||
hue < 180f -> Triple(0f, c, x)
|
|
||||||
hue < 240f -> Triple(0f, x, c)
|
|
||||||
hue < 300f -> Triple(x, 0f, c)
|
|
||||||
else -> Triple(c, 0f, x)
|
|
||||||
}
|
|
||||||
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
|
||||||
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
|
||||||
private const val PASTEL_VALUE = 0.82f
|
|
||||||
|
|
||||||
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||||
private const val CURATION_TRIGGER_SIZE = 36
|
private const val CURATION_TRIGGER_SIZE = 36
|
||||||
|
|
||||||
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
|
||||||
private const val MIN_DELTA_E = 13.0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
* Minimum Oklab distance between surviving painted swatches. Painted colours all
|
||||||
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
* share one lightness, so this is really a hue/chroma separation — far enough
|
||||||
* is dropped rather than shown as pale filler.
|
* apart that two swatches never read as the same colour in the grid.
|
||||||
*/
|
*/
|
||||||
private const val PASTEL_CHROMA_FLOOR = 22.0
|
private const val MIN_DISTANCE = 0.025f
|
||||||
|
|
||||||
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
|
||||||
private class Lab(val l: Double, val a: Double, val b: Double) {
|
|
||||||
val chroma: Double get() = hypot(a, b)
|
|
||||||
|
|
||||||
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
|
||||||
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
|
||||||
|
|
||||||
fun deltaE(other: Lab): Double =
|
|
||||||
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun of(argb: Int): Lab {
|
|
||||||
fun linear(shift: Int): Double {
|
|
||||||
val c = ((argb shr shift) and 0xFF) / 255.0
|
|
||||||
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
|
||||||
}
|
|
||||||
val r = linear(16)
|
|
||||||
val g = linear(8)
|
|
||||||
val b = linear(0)
|
|
||||||
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
|
||||||
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
||||||
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
|
||||||
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
|
||||||
val fy = f(y)
|
|
||||||
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** An event with the free text the search may have matched it on. */
|
||||||
|
data class SearchCandidate(
|
||||||
|
val event: EventInstance,
|
||||||
|
val description: String? = null,
|
||||||
|
/** Null when the event has none: [EventInstance.title] is then a display
|
||||||
|
* placeholder, which the query must not catch on. */
|
||||||
|
val title: String? = event.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A matched run inside one of a hit's texts, for highlighting. */
|
||||||
|
data class MatchSpan(val start: Int, val end: Int)
|
||||||
|
|
||||||
|
/** The part of a description a token matched, elided to what fits a row. */
|
||||||
|
data class DescriptionSnippet(
|
||||||
|
val text: String,
|
||||||
|
val spans: List<MatchSpan>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One result: the event, plus where the query matched it. */
|
||||||
|
data class SearchHit(
|
||||||
|
val event: EventInstance,
|
||||||
|
val titleSpans: List<MatchSpan> = emptyList(),
|
||||||
|
val locationSpans: List<MatchSpan> = emptyList(),
|
||||||
|
val descriptionSnippet: DescriptionSnippet? = null,
|
||||||
|
/** Already over, so the row can say so. Finished-today counts. */
|
||||||
|
val isPast: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One month's worth of hits, as the results list draws them under a header. */
|
||||||
|
data class SearchMonth(
|
||||||
|
val year: Int,
|
||||||
|
/** 1–12, so the UI can build the month's first day for a localized label. */
|
||||||
|
val monthNumber: Int,
|
||||||
|
val hits: List<SearchHit>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A finished search: a calendar of hits, plus the description-only ones. */
|
||||||
|
data class SearchResults(
|
||||||
|
val months: List<SearchMonth>,
|
||||||
|
val inDescriptions: List<SearchHit>,
|
||||||
|
) {
|
||||||
|
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
|
||||||
|
|
||||||
|
/** Every hit, whichever section it sits in — for select-all and delete. */
|
||||||
|
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turning a typed query into the results list.
|
||||||
|
*
|
||||||
|
* An event has to carry *every* whitespace-separated token, each in any of
|
||||||
|
* title / location / description; case is folded in Kotlin rather than by SQL's
|
||||||
|
* ASCII-only `LIKE`, so "ärzte" finds "Ärzte".
|
||||||
|
*
|
||||||
|
* Results are a calendar, not a ranking: months from the current one, then
|
||||||
|
* forwards, then backwards, and inside a month what is still to come before
|
||||||
|
* what has passed. The day is the boundary, not the moment. A hit the query
|
||||||
|
* only reached through its description is held back in
|
||||||
|
* [SearchResults.inDescriptions] rather than dated among the real ones.
|
||||||
|
*/
|
||||||
|
object EventSearch {
|
||||||
|
|
||||||
|
/** Characters of description kept around the match in a row's snippet. */
|
||||||
|
private const val SNIPPET_LENGTH = 96
|
||||||
|
|
||||||
|
/** Characters of lead-in shown before the match, when there is room. */
|
||||||
|
private const val SNIPPET_LEAD = 24
|
||||||
|
|
||||||
|
private val WHITESPACE = Regex("\\s+")
|
||||||
|
|
||||||
|
/** Shared with the data layer so its SQL pre-filter tokenises identically. */
|
||||||
|
fun tokenize(query: String): List<String> =
|
||||||
|
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search [candidates] for [query], dropping any that don't carry every token.
|
||||||
|
* [now] marks a hit as over, [todayStart] is where it stops being current.
|
||||||
|
* [zone] places each event on the calendar through the span rule, so an
|
||||||
|
* all-day event keeps its own day whichever side of UTC we are on (#82).
|
||||||
|
*/
|
||||||
|
fun search(
|
||||||
|
candidates: List<SearchCandidate>,
|
||||||
|
query: String,
|
||||||
|
now: Instant,
|
||||||
|
todayStart: Instant,
|
||||||
|
zone: TimeZone,
|
||||||
|
): SearchResults {
|
||||||
|
val tokens = tokenize(query)
|
||||||
|
if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
|
||||||
|
|
||||||
|
val today = todayStart.toLocalDateTime(zone).date
|
||||||
|
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now, zone) }
|
||||||
|
val upcoming = { m: Matched -> m.event.spanLastDay(zone) >= today }
|
||||||
|
val order = compareBy<Matched> { if (upcoming(it)) 0 else 1 }
|
||||||
|
.thenComparator { a, b ->
|
||||||
|
// What is left reads forwards; what is behind us reads backwards
|
||||||
|
// from today, nearest first.
|
||||||
|
if (upcoming(a)) {
|
||||||
|
a.event.dayStart(zone).compareTo(b.event.dayStart(zone))
|
||||||
|
} else {
|
||||||
|
b.event.dayStart(zone).compareTo(a.event.dayStart(zone))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
|
||||||
|
return SearchResults(
|
||||||
|
months = groupByMonth(dated, zone, todayStart),
|
||||||
|
inDescriptions = inDescriptions.map { it.hit },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Months from the current one, then forwards, then backwards. */
|
||||||
|
private fun groupByMonth(
|
||||||
|
dated: List<Matched>,
|
||||||
|
zone: TimeZone,
|
||||||
|
todayStart: Instant,
|
||||||
|
): List<SearchMonth> {
|
||||||
|
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
|
||||||
|
return dated
|
||||||
|
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
|
||||||
|
.entries
|
||||||
|
.sortedWith(
|
||||||
|
compareBy<Map.Entry<Int, List<Matched>>> {
|
||||||
|
when {
|
||||||
|
it.key == currentKey -> 0
|
||||||
|
it.key > currentKey -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
}.thenComparator { a, b ->
|
||||||
|
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map { (key, entries) ->
|
||||||
|
SearchMonth(
|
||||||
|
year = key / 12,
|
||||||
|
monthNumber = key % 12 + 1,
|
||||||
|
hits = entries.map { it.hit },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Year and month as one comparable number. */
|
||||||
|
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
|
||||||
|
|
||||||
|
private fun match(
|
||||||
|
candidate: SearchCandidate,
|
||||||
|
tokens: List<String>,
|
||||||
|
now: Instant,
|
||||||
|
zone: TimeZone,
|
||||||
|
): Matched? {
|
||||||
|
val title = candidate.title?.takeIf { it.isNotBlank() }
|
||||||
|
val location = candidate.event.location?.takeIf { it.isNotBlank() }
|
||||||
|
// Collapsed so a multi-line description matches the way it is drawn.
|
||||||
|
val description = candidate.description
|
||||||
|
?.replace(WHITESPACE, " ")
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
|
||||||
|
// Mid-word counts: "termin" has to keep finding "Zahnarzttermin".
|
||||||
|
val present = { token: String ->
|
||||||
|
title?.contains(token, ignoreCase = true) == true ||
|
||||||
|
location?.contains(token, ignoreCase = true) == true ||
|
||||||
|
description?.contains(token, ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
if (!tokens.all(present)) return null
|
||||||
|
|
||||||
|
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
|
||||||
|
val titleSpans = title?.let { spansIn(it, tokens) }.orEmpty()
|
||||||
|
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
|
||||||
|
return Matched(
|
||||||
|
hit = SearchHit(
|
||||||
|
event = candidate.event,
|
||||||
|
titleSpans = titleSpans,
|
||||||
|
locationSpans = locationSpans,
|
||||||
|
descriptionSnippet = description
|
||||||
|
?.takeIf { descriptionSpans.isNotEmpty() }
|
||||||
|
?.let { snippet(it, descriptionSpans) },
|
||||||
|
isPast = candidate.event.dayEnd(zone) < now,
|
||||||
|
),
|
||||||
|
// Nothing in the name or the place caught the query: it is only here
|
||||||
|
// for something in its notes.
|
||||||
|
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every occurrence of every token in [text], merged where they overlap. */
|
||||||
|
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
|
||||||
|
val raw = mutableListOf<MatchSpan>()
|
||||||
|
for (token in tokens) {
|
||||||
|
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
|
||||||
|
while (index >= 0) {
|
||||||
|
raw += MatchSpan(index, index + token.length)
|
||||||
|
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw.isEmpty()) return emptyList()
|
||||||
|
val sorted = raw.sortedBy { it.start }
|
||||||
|
val merged = mutableListOf(sorted.first())
|
||||||
|
for (span in sorted.drop(1)) {
|
||||||
|
val last = merged.last()
|
||||||
|
if (span.start <= last.end) {
|
||||||
|
merged[merged.lastIndex] = MatchSpan(last.start, maxOf(last.end, span.end))
|
||||||
|
} else {
|
||||||
|
merged += span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A window of [description] around its first match, elided at both ends,
|
||||||
|
* with the spans rebased onto it and any falling outside dropped.
|
||||||
|
*/
|
||||||
|
private fun snippet(description: String, spans: List<MatchSpan>): DescriptionSnippet {
|
||||||
|
if (description.length <= SNIPPET_LENGTH) {
|
||||||
|
return DescriptionSnippet(description, spans)
|
||||||
|
}
|
||||||
|
val first = spans.first().start
|
||||||
|
val start = (first - SNIPPET_LEAD).coerceIn(0, (description.length - SNIPPET_LENGTH))
|
||||||
|
val end = (start + SNIPPET_LENGTH).coerceAtMost(description.length)
|
||||||
|
val prefix = if (start > 0) "…" else ""
|
||||||
|
val suffix = if (end < description.length) "…" else ""
|
||||||
|
val text = prefix + description.substring(start, end) + suffix
|
||||||
|
val shift = prefix.length - start
|
||||||
|
val rebased = spans
|
||||||
|
.filter { it.start >= start && it.end <= end }
|
||||||
|
.map { MatchSpan(it.start + shift, it.end + shift) }
|
||||||
|
return DescriptionSnippet(text, rebased)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
|
||||||
|
val event: EventInstance get() = hit.event
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's first day as an instant in [zone]. An all-day event sits at
|
||||||
|
* UTC midnight, so its raw start would rank it on a day it isn't drawn on.
|
||||||
|
*/
|
||||||
|
private fun EventInstance.dayStart(zone: TimeZone): Instant =
|
||||||
|
if (isAllDay) spanFirstDay(zone).atStartOfDayIn(zone) else start
|
||||||
|
|
||||||
|
/** The moment the event's last day is over in [zone], exclusive. */
|
||||||
|
private fun EventInstance.dayEnd(zone: TimeZone): Instant =
|
||||||
|
if (isAllDay) spanLastDay(zone).plus(1, DateTimeUnit.DAY).atStartOfDayIn(zone) else end
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone this form's wall-clock times mean, matching what the data layer
|
||||||
|
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
||||||
|
*/
|
||||||
|
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||||
|
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form moved so it starts at [newStart], keeping its **instant** duration —
|
||||||
|
* a recurring event's length travels to the provider as `DURATION`, so keeping
|
||||||
|
* wall clock instead would rewrite the series' length across a DST boundary.
|
||||||
|
*
|
||||||
|
* All-day events are date-anchored — use [shiftedByDays]; this returns them
|
||||||
|
* untouched.
|
||||||
|
*/
|
||||||
|
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
||||||
|
if (isAllDay) return this
|
||||||
|
val zone = resolvedZone(deviceZone)
|
||||||
|
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||||
|
return copy(
|
||||||
|
start = newStart.toLocalDateTime(zone),
|
||||||
|
end = (newStart + span).toLocalDateTime(zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form moved [days] calendar days, keeping its time of day and its span.
|
||||||
|
* All-day events are pure date arithmetic; a timed one preserves its **instant**
|
||||||
|
* duration, for the same reason [shiftedTo] does.
|
||||||
|
*/
|
||||||
|
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||||
|
if (days == 0) return this
|
||||||
|
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
||||||
|
if (isAllDay) {
|
||||||
|
return copy(
|
||||||
|
start = newStart,
|
||||||
|
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val zone = resolvedZone(deviceZone)
|
||||||
|
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||||
|
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ import kotlin.time.Instant
|
|||||||
* calendar this way to create a new event, passing the fields as
|
* calendar this way to create a new event, passing the fields as
|
||||||
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||||
* back to the same defaults the in-app "new event" uses — a timed start at the
|
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||||
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
|
* next full hour, and a placeholder hour for a missing end that the form then
|
||||||
|
* stretches to the default-duration setting (the intent is opened as
|
||||||
|
* `ImportSource.InsertOpenEnded`, #54). [EventForm.calendarId] is left null so
|
||||||
* it resolves to the last-used / first-writable calendar, exactly like the
|
* it resolves to the last-used / first-writable calendar, exactly like the
|
||||||
* `.ics` single-event and plain new-event paths.
|
* `.ics` single-event and plain new-event paths.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ data class EventInstance(
|
|||||||
val isAllDay: Boolean,
|
val isAllDay: Boolean,
|
||||||
val color: Int,
|
val color: Int,
|
||||||
val location: String?,
|
val location: String?,
|
||||||
|
/**
|
||||||
|
* Only search results, which read the series master, fill this in — the
|
||||||
|
* Instances query already yields one row per occurrence.
|
||||||
|
*/
|
||||||
|
val isRecurring: Boolean = false,
|
||||||
|
/**
|
||||||
|
* This device user answered "no" to the invitation
|
||||||
|
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is
|
||||||
|
* still an appointment someone expects an answer about — but every surface
|
||||||
|
* strikes it through, and it plans no reminders (#180).
|
||||||
|
*/
|
||||||
|
val isDeclined: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,6 +136,12 @@ data class EventDetail(
|
|||||||
val eventColor: Int? = null,
|
val eventColor: Int? = null,
|
||||||
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||||
val eventColorKey: String? = null,
|
val eventColorKey: String? = null,
|
||||||
|
/**
|
||||||
|
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
|
||||||
|
* set) rather than a master, so a reschedule takes the plain whole-row path
|
||||||
|
* whatever [rrule] a sync adapter left on it.
|
||||||
|
*/
|
||||||
|
val isException: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||||
DayOfWeek.MONDAY to "MO",
|
DayOfWeek.MONDAY to "MO",
|
||||||
DayOfWeek.TUESDAY to "TU",
|
DayOfWeek.TUESDAY to "TU",
|
||||||
DayOfWeek.WEDNESDAY to "WE",
|
DayOfWeek.WEDNESDAY to "WE",
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
|
||||||
|
*
|
||||||
|
* `Events.RRULE` is written verbatim while DTSTART moves, so `FREQ=WEEKLY;BYDAY=MO`
|
||||||
|
* would keep naming Monday after the anchor became a Wednesday and the series
|
||||||
|
* would not move at all — `BYDAY` is re-derived from [newStart].
|
||||||
|
*
|
||||||
|
* Only weekly `BYDAY` is realigned, and only for a whole-day move: the rule has
|
||||||
|
* to agree with the series *anchor*, and weekday arithmetic is the only kind
|
||||||
|
* that survives the same wall-clock shift unchanged. Everything else returns
|
||||||
|
* null, as do rules one moved occurrence can't resolve (`BYDAY=MO,WE`, `2TH`,
|
||||||
|
* `BYSETPOS`). The accepted parts are a subset of [parseSimpleRecurrence], so
|
||||||
|
* anything realignable is also a rule [problems] can check the `UNTIL` of.
|
||||||
|
*/
|
||||||
|
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
||||||
|
if (oldStart == newStart) return rrule
|
||||||
|
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
||||||
|
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
|
||||||
|
if (parts.isEmpty()) return null
|
||||||
|
var weekly = false
|
||||||
|
val rebuilt = parts.map { part ->
|
||||||
|
val eq = part.indexOf('=')
|
||||||
|
if (eq <= 0) return null
|
||||||
|
val key = part.substring(0, eq).uppercase()
|
||||||
|
val value = part.substring(eq + 1).trim()
|
||||||
|
when (key) {
|
||||||
|
"FREQ" -> {
|
||||||
|
weekly = value.equals("WEEKLY", ignoreCase = true)
|
||||||
|
part
|
||||||
|
}
|
||||||
|
"BYDAY" -> {
|
||||||
|
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
||||||
|
if (!value.equals(old, ignoreCase = true)) return null
|
||||||
|
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
||||||
|
}
|
||||||
|
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// BYDAY is only simple on a weekly rule, matching parseSimpleRecurrence.
|
||||||
|
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
||||||
|
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
||||||
|
return prefix + rebuilt.joinToString(";")
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colours one calendar's identity resolves to in one theme.
|
||||||
|
*
|
||||||
|
* The app paints a calendar's colour in two structurally different places, and
|
||||||
|
* they want opposite things:
|
||||||
|
*
|
||||||
|
* - a **[container]** sits behind text (week and day blocks, month bars, widget
|
||||||
|
* rows, picker swatches), so it has to contrast with its own ink;
|
||||||
|
* - an **[accent]** is a mark on an ordinary app surface (day dots, agenda and
|
||||||
|
* search stripes, calendar icon tints, the detail header), so it has to
|
||||||
|
* contrast with the *surface* instead.
|
||||||
|
*
|
||||||
|
* Painting both from one colour is what made this hard to get right: deep enough
|
||||||
|
* to carry white text is too dark to see as a dot on a dark surface, and light
|
||||||
|
* enough to show up there is too pale to carry white text. Splitting them lets
|
||||||
|
* each be pinned to the lightness its job needs, while hue and chroma — the parts
|
||||||
|
* that actually say *which calendar this is* — stay shared, so the two roles
|
||||||
|
* still read as the same colour.
|
||||||
|
*/
|
||||||
|
data class EventTone(
|
||||||
|
val container: Int,
|
||||||
|
val onContainer: Int,
|
||||||
|
val accent: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve [rawArgb] for the current theme.
|
||||||
|
*
|
||||||
|
* With [harmonise] on, hue and chroma are kept and lightness is re-pinned per
|
||||||
|
* role, which is what makes the ink predictable: every container lands at the
|
||||||
|
* same perceptual lightness, so one ink colour serves every hue at ≥ 6:1. With
|
||||||
|
* it off the provider's colour is painted verbatim — the sync source's own look,
|
||||||
|
* as DAVx5/CalDAV users expect — and the ink is then chosen per colour, since
|
||||||
|
* nothing constrains what the provider sends.
|
||||||
|
*/
|
||||||
|
fun eventTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): EventTone {
|
||||||
|
val opaque = rawArgb or 0xFF000000.toInt()
|
||||||
|
val raw = oklchOf(opaque)
|
||||||
|
if (!harmonise) {
|
||||||
|
return EventTone(
|
||||||
|
container = opaque,
|
||||||
|
onContainer = inkFor(raw.lightness),
|
||||||
|
accent = opaque,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// A near-grey source has no hue worth keeping, so it stays grey rather than
|
||||||
|
// being pushed to an arbitrary one; everything else is held inside a band
|
||||||
|
// that keeps calendars apart without going neon.
|
||||||
|
val chroma = if (raw.chroma < GREY_CHROMA) 0f else raw.chroma.coerceIn(MIN_CHROMA, MAX_CHROMA)
|
||||||
|
// Two poles rather than one. Forcing every hue deep is what turned the warm,
|
||||||
|
// naturally light ones muddy — a dark orange is brown and a dark yellow is
|
||||||
|
// olive, which is a fact about those hues, not a tuning miss. So a colour is
|
||||||
|
// sent to whichever pole it already sits nearer: most land deep and carry
|
||||||
|
// white ink, while genuinely light sources (yellows, creams, pale tints)
|
||||||
|
// stay light and carry dark ink, keeping the character that made them
|
||||||
|
// recognisable. Either way the colour is pulled clear of the middle, where
|
||||||
|
// neither ink reads well — which was the original fault.
|
||||||
|
val staysLight = raw.lightness >= LIGHT_POLE_THRESHOLD
|
||||||
|
val containerLightness = when {
|
||||||
|
staysLight && dark -> LIGHT_CONTAINER_LIGHTNESS_DARK
|
||||||
|
staysLight -> LIGHT_CONTAINER_LIGHTNESS_LIGHT
|
||||||
|
dark -> CONTAINER_LIGHTNESS_DARK
|
||||||
|
else -> CONTAINER_LIGHTNESS_LIGHT
|
||||||
|
}
|
||||||
|
// The accent takes no pole: it is a mark on the app's surface, so it has to
|
||||||
|
// contrast with that surface whichever way its container went.
|
||||||
|
val accentLightness = if (dark) ACCENT_LIGHTNESS_DARK else ACCENT_LIGHTNESS_LIGHT
|
||||||
|
return EventTone(
|
||||||
|
container = Oklch(containerLightness, chroma, raw.hue).toArgb(),
|
||||||
|
onContainer = inkFor(containerLightness),
|
||||||
|
accent = Oklch(accentLightness, chroma, raw.hue).toArgb(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* White or black ink for a background of the given perceptual [lightness].
|
||||||
|
*
|
||||||
|
* Derived rather than hardcoded so it stays right for raw provider colours,
|
||||||
|
* where lightness is whatever the sync source sent. For harmonised containers it
|
||||||
|
* is constant by construction — that is the point of pinning the lightness.
|
||||||
|
*/
|
||||||
|
fun inkFor(lightness: Float): Int =
|
||||||
|
if (lightness < INK_FLIP_LIGHTNESS) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightness at which white ink overtakes black. Sits above the midpoint because
|
||||||
|
* lightness is perceptual: a colour has to be distinctly light before black wins.
|
||||||
|
*/
|
||||||
|
const val INK_FLIP_LIGHTNESS = 0.62f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw lightness at or above which a colour keeps its light character instead of
|
||||||
|
* being pushed deep. Set high enough that oranges and warm reds still go deep —
|
||||||
|
* a burnt orange reads as orange, where a dark yellow does not read as yellow —
|
||||||
|
* so only the genuinely pale sources take the light pole.
|
||||||
|
*/
|
||||||
|
const val LIGHT_POLE_THRESHOLD = 0.72f
|
||||||
|
|
||||||
|
/** Container lightness: deep enough that white ink clears 5.5:1 on every hue. */
|
||||||
|
const val CONTAINER_LIGHTNESS_LIGHT = 0.45f
|
||||||
|
|
||||||
|
/** Same in dark mode, a shade lighter so a block separates from the surface. */
|
||||||
|
const val CONTAINER_LIGHTNESS_DARK = 0.48f
|
||||||
|
|
||||||
|
/** Light-pole container: pale enough that dark ink clears 11:1 on every hue. */
|
||||||
|
const val LIGHT_CONTAINER_LIGHTNESS_LIGHT = 0.88f
|
||||||
|
|
||||||
|
/** Same in dark mode, held down a little so a pale block doesn't glare. */
|
||||||
|
const val LIGHT_CONTAINER_LIGHTNESS_DARK = 0.84f
|
||||||
|
|
||||||
|
/** Accent lightness: dark enough to read as a mark on a pale surface. */
|
||||||
|
const val ACCENT_LIGHTNESS_LIGHT = 0.55f
|
||||||
|
|
||||||
|
/** Same against a dark surface, where the mark has to be the light one. */
|
||||||
|
const val ACCENT_LIGHTNESS_DARK = 0.78f
|
||||||
|
|
||||||
|
/** Below this chroma a colour counts as grey and keeps no hue. */
|
||||||
|
const val GREY_CHROMA = 0.02f
|
||||||
|
|
||||||
|
/** Chroma band for harmonised colours: distinct, but never electric. */
|
||||||
|
const val MIN_CHROMA = 0.07f
|
||||||
|
const val MAX_CHROMA = 0.16f
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.color
|
||||||
|
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cbrt
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A colour in Oklch — Oklab's cylindrical form: perceptual [lightness], [chroma]
|
||||||
|
* (colourfulness) and [hue] in degrees.
|
||||||
|
*
|
||||||
|
* The point of using it over HSV is that its lightness axis matches what the eye
|
||||||
|
* calls brightness. HSV's "value" does not: at a single pinned value the hues
|
||||||
|
* spread across relative luminance 0.10 (indigo) to 0.45 (yellow), which is why
|
||||||
|
* pinning value produced fills that needed different ink per hue. Pin Oklch
|
||||||
|
* lightness instead and every hue lands at the same apparent brightness, so one
|
||||||
|
* ink serves all of them.
|
||||||
|
*
|
||||||
|
* [lightness] runs 0 (black) to 1 (white); [chroma] is 0 (grey) to about 0.32 at
|
||||||
|
* the sRGB limit.
|
||||||
|
*/
|
||||||
|
data class Oklch(val lightness: Float, val chroma: Float, val hue: Float) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perceptual distance to [other] — plain Euclidean in Oklab, which is what
|
||||||
|
* Oklab is built for (unlike CIE Lab, where CIE76 is known to misjudge
|
||||||
|
* saturated blues).
|
||||||
|
*/
|
||||||
|
fun distanceTo(other: Oklch): Float {
|
||||||
|
val (a1, b1) = chroma * cosDeg(hue) to chroma * sinDeg(hue)
|
||||||
|
val (a2, b2) = other.chroma * cosDeg(other.hue) to other.chroma * sinDeg(other.hue)
|
||||||
|
val dl = (lightness - other.lightness).toDouble()
|
||||||
|
return sqrt(dl * dl + (a1 - a2).pow(2) + (b1 - b2).pow(2)).toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read [argb]'s opaque colour as Oklch. */
|
||||||
|
fun oklchOf(argb: Int): Oklch {
|
||||||
|
val r = toLinear(((argb shr 16) and 0xFF) / 255.0)
|
||||||
|
val g = toLinear(((argb shr 8) and 0xFF) / 255.0)
|
||||||
|
val b = toLinear((argb and 0xFF) / 255.0)
|
||||||
|
|
||||||
|
val l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
||||||
|
val m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
||||||
|
val s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
||||||
|
|
||||||
|
val lightness = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
|
||||||
|
val aAxis = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
|
||||||
|
val bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
|
||||||
|
|
||||||
|
val hue = (Math.toDegrees(atan2(bAxis, aAxis)) + 360.0) % 360.0
|
||||||
|
return Oklch(lightness.toFloat(), hypot(aAxis, bAxis).toFloat(), hue.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opaque sRGB colour for this Oklch, gamut-mapped: most of the Oklch cylinder
|
||||||
|
* falls outside sRGB, so a colour that does not fit keeps its lightness and hue
|
||||||
|
* and gives up chroma until it does. Holding lightness is what matters here —
|
||||||
|
* it's the axis the contrast guarantees rest on.
|
||||||
|
*/
|
||||||
|
fun Oklch.toArgb(): Int {
|
||||||
|
val fitted = if (inSrgb(lightness, chroma, hue)) {
|
||||||
|
chroma
|
||||||
|
} else {
|
||||||
|
var low = 0f
|
||||||
|
var high = chroma
|
||||||
|
repeat(GAMUT_STEPS) {
|
||||||
|
val mid = (low + high) / 2f
|
||||||
|
if (inSrgb(lightness, mid, hue)) low = mid else high = mid
|
||||||
|
}
|
||||||
|
low
|
||||||
|
}
|
||||||
|
val (r, g, b) = linearSrgbOf(lightness, fitted, hue)
|
||||||
|
return 0xFF shl 24 or
|
||||||
|
(channel(r) shl 16) or
|
||||||
|
(channel(g) shl 8) or
|
||||||
|
channel(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linearSrgbOf(lightness: Float, chroma: Float, hue: Float): Triple<Double, Double, Double> {
|
||||||
|
val a = chroma * cosDeg(hue)
|
||||||
|
val b = chroma * sinDeg(hue)
|
||||||
|
val l = (lightness + 0.3963377774 * a + 0.2158037573 * b).pow(3)
|
||||||
|
val m = (lightness - 0.1055613458 * a - 0.0638541728 * b).pow(3)
|
||||||
|
val s = (lightness - 0.0894841775 * a - 1.2914855480 * b).pow(3)
|
||||||
|
return Triple(
|
||||||
|
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||||
|
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||||
|
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inSrgb(lightness: Float, chroma: Float, hue: Float): Boolean {
|
||||||
|
val (r, g, b) = linearSrgbOf(lightness, chroma, hue)
|
||||||
|
return r in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
||||||
|
g in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
||||||
|
b in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun channel(linear: Double): Int =
|
||||||
|
(toSrgb(linear.coerceIn(0.0, 1.0)) * 255.0).toInt().coerceIn(0, 255)
|
||||||
|
|
||||||
|
private fun toLinear(c: Double): Double =
|
||||||
|
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||||
|
|
||||||
|
private fun toSrgb(c: Double): Double =
|
||||||
|
if (c <= 0.0031308) 12.92 * c else 1.055 * c.pow(1.0 / 2.4) - 0.055
|
||||||
|
|
||||||
|
private fun cosDeg(deg: Float) = cos(Math.toRadians(deg.toDouble()))
|
||||||
|
private fun sinDeg(deg: Float) = sin(Math.toRadians(deg.toDouble()))
|
||||||
|
|
||||||
|
/** Bisection steps when pulling an out-of-gamut colour back into sRGB. */
|
||||||
|
private const val GAMUT_STEPS = 24
|
||||||
|
|
||||||
|
/** Slack for the gamut test, so rounding at the boundary doesn't reject a fit. */
|
||||||
|
private const val GAMUT_EPSILON = 1e-4
|
||||||
@@ -10,6 +10,7 @@ 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.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -27,6 +28,10 @@ import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
|||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||||
@@ -75,6 +80,7 @@ fun CalendarHost(
|
|||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: EventForm? = null,
|
requestedInsertForm: EventForm? = null,
|
||||||
|
requestedInsertSource: ImportSource = ImportSource.Insert,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
requestedEditKey: LongArray? = null,
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
@@ -203,7 +209,7 @@ fun CalendarHost(
|
|||||||
// reveals on top of whatever was open without extra dismissal.
|
// reveals on top of whatever was open without extra dismissal.
|
||||||
LaunchedEffect(requestedInsertForm) {
|
LaunchedEffect(requestedInsertForm) {
|
||||||
if (requestedInsertForm != null) {
|
if (requestedInsertForm != null) {
|
||||||
importFormSource = ImportSource.Insert
|
importFormSource = requestedInsertSource
|
||||||
importForm = requestedInsertForm
|
importForm = requestedInsertForm
|
||||||
onInsertConsumed()
|
onInsertConsumed()
|
||||||
}
|
}
|
||||||
@@ -293,6 +299,33 @@ fun CalendarHost(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drag to reschedule (#68). Hosted here so one instance serves every view and
|
||||||
|
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
||||||
|
val reschedule: RescheduleViewModel = hiltViewModel()
|
||||||
|
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
||||||
|
val onEditEvent: (EventInstance) -> Unit = { event ->
|
||||||
|
val key = longArrayOf(
|
||||||
|
event.eventId,
|
||||||
|
event.start.toEpochMilliseconds(),
|
||||||
|
event.end.toEpochMilliseconds(),
|
||||||
|
)
|
||||||
|
heldEditKey = key
|
||||||
|
editKey = key
|
||||||
|
}
|
||||||
|
// Off by preference (#173) switches off the gesture, not the scope: blocks
|
||||||
|
// register no drag, while the edit form and its TalkBack action stay put.
|
||||||
|
val dragToReschedule = viewModel.dragToReschedule.collectAsStateWithLifecycle().value
|
||||||
|
val moveScope = remember(movableCalendarIds, reschedule, dragToReschedule) {
|
||||||
|
EventMoveScope(
|
||||||
|
movableCalendarIds = movableCalendarIds,
|
||||||
|
dragEnabled = dragToReschedule,
|
||||||
|
move = reschedule::move,
|
||||||
|
inFlight = reschedule.inFlight,
|
||||||
|
undoStarted = reschedule.undoStarted,
|
||||||
|
edit = onEditEvent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
// 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
|
||||||
@@ -310,6 +343,7 @@ fun CalendarHost(
|
|||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = fadeThrough()
|
||||||
|
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
@@ -366,6 +400,11 @@ fun CalendarHost(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope prompt + confirmation/undo snackbar for a dropped event, declared
|
||||||
|
// right after the calendar views so later overlays cover it.
|
||||||
|
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
||||||
|
|
||||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||||
// detail screen draws on top, and closing it returns to the results.
|
// detail screen draws on top, and closing it returns to the results.
|
||||||
|
|||||||
@@ -53,4 +53,18 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether events may be dragged to another slot to reschedule them (#68, #173).
|
||||||
|
* Off until the stored value arrives, even though the preference itself defaults
|
||||||
|
* on: the wrong guess costs an enabled user a gesture for the few frames a cold
|
||||||
|
* start takes to read DataStore, but re-arms the one thing a disabled user
|
||||||
|
* switched off to be rid of.
|
||||||
|
*/
|
||||||
|
val dragToReschedule: StateFlow<Boolean> = prefs.dragToReschedule
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package de.jeanlucmakiola.calendula.ui
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -22,13 +25,22 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeDialog
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarVisibilityNoticeViewModel
|
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingStep
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingSteps
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.onboarding.OnboardingViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.onboarding.onboardingSlideTransition
|
||||||
|
import de.jeanlucmakiola.calendula.ui.onboarding.rememberOnboardingSlideSpec
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
|
||||||
|
/** What the root is showing: a wizard step, the app, or neither yet. */
|
||||||
|
private sealed interface RootTarget {
|
||||||
|
/** DataStore has not emitted yet — render nothing rather than the wrong screen. */
|
||||||
|
data object Loading : RootTarget
|
||||||
|
data object App : RootTarget
|
||||||
|
data class Step(val step: OnboardingStep) : RootTarget
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(
|
fun RootScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -39,6 +51,8 @@ fun RootScreen(
|
|||||||
requestedImportUri: android.net.Uri? = null,
|
requestedImportUri: android.net.Uri? = null,
|
||||||
onImportConsumed: () -> Unit = {},
|
onImportConsumed: () -> Unit = {},
|
||||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||||
|
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
|
||||||
|
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
|
||||||
onInsertConsumed: () -> Unit = {},
|
onInsertConsumed: () -> Unit = {},
|
||||||
requestedEditKey: LongArray? = null,
|
requestedEditKey: LongArray? = null,
|
||||||
onEditKeyConsumed: () -> Unit = {},
|
onEditKeyConsumed: () -> Unit = {},
|
||||||
@@ -74,57 +88,87 @@ fun RootScreen(
|
|||||||
onDispose { lifecycle.removeObserver(obs) }
|
onDispose { lifecycle.removeObserver(obs) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cross-fade the one-time onboarding gates so granting permission / finishing
|
val onboarding: OnboardingViewModel = hiltViewModel()
|
||||||
// onboarding eases into the next screen instead of snapping. A fade carries no
|
val plan by onboarding.plan.collectAsStateWithLifecycle()
|
||||||
// spatial motion, so it stays appropriate under "remove animations" too.
|
// The grant is reported however it was taken, including via Android's
|
||||||
val gateSpec = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
// app-settings screen (caught by the ON_RESUME above); arming the wizard
|
||||||
Crossfade(targetState = hasPermission, animationSpec = gateSpec, label = "permissionGate") { granted ->
|
// hangs off that, not off the in-app callback.
|
||||||
if (granted) {
|
LaunchedEffect(hasPermission) {
|
||||||
// Second onboarding gate (v1.4, one-time): reminder notifications.
|
onboarding.setHasPermission(hasPermission)
|
||||||
// Null until DataStore's first emission — render nothing for that
|
if (hasPermission && !grantedAtLaunch) onboarding.onPermissionGranted()
|
||||||
// frame instead of flashing the wrong screen.
|
}
|
||||||
val reminderOnboarding: ReminderOnboardingViewModel = hiltViewModel()
|
|
||||||
val onboardingDone by reminderOnboarding.onboardingDone.collectAsStateWithLifecycle()
|
// Arms the one-time explainer for the switch to the device's own calendar
|
||||||
// One-time explainer for the switch to the device's own calendar
|
// visibility (#75), which the wizard then carries as a step of its own.
|
||||||
// visibility (#75), armed by the reconciler.
|
LaunchedEffect(hasPermission) {
|
||||||
val visibilityNotice: CalendarVisibilityNoticeViewModel = hiltViewModel()
|
if (hasPermission) onboarding.reconcileCalendarVisibility()
|
||||||
val noticePending by visibilityNotice.pending.collectAsStateWithLifecycle()
|
}
|
||||||
// Runs on entry however the permission was granted, including via
|
|
||||||
// Android's app-settings screen (caught by the ON_RESUME above).
|
val current = plan
|
||||||
LaunchedEffect(Unit) {
|
val target: RootTarget = when {
|
||||||
visibilityNotice.reconcile()
|
current == null -> RootTarget.Loading
|
||||||
if (!grantedAtLaunch) reminderOnboarding.rearmAfterGrant()
|
current.current == null -> RootTarget.App
|
||||||
|
else -> RootTarget.Step(current.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which way the flow is moving, so a step-back slides back. The app sorts
|
||||||
|
// after every step, so finishing the wizard reads as one more move forward.
|
||||||
|
val ordinal = when (target) {
|
||||||
|
RootTarget.Loading -> Int.MIN_VALUE
|
||||||
|
RootTarget.App -> Int.MAX_VALUE
|
||||||
|
is RootTarget.Step -> current?.steps?.indexOf(target.step) ?: 0
|
||||||
|
}
|
||||||
|
var lastOrdinal by remember { mutableIntStateOf(ordinal) }
|
||||||
|
val slideDir = if (ordinal < lastOrdinal) -1 else 1
|
||||||
|
SideEffect { lastOrdinal = ordinal }
|
||||||
|
|
||||||
|
val slideSpec = rememberOnboardingSlideSpec()
|
||||||
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = target,
|
||||||
|
transitionSpec = {
|
||||||
|
// Coming off the blank first frame is not a step change.
|
||||||
|
if (initialState == RootTarget.Loading) {
|
||||||
|
fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
|
} else {
|
||||||
|
onboardingSlideTransition(
|
||||||
|
slideDir = slideDir,
|
||||||
|
spec = slideSpec,
|
||||||
|
fadeSpec = fadeSpec,
|
||||||
|
reduceMotion = reduceMotion,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (onboardingDone == true && noticePending) {
|
},
|
||||||
CalendarVisibilityNoticeDialog(onDismiss = visibilityNotice::dismiss)
|
label = "onboardingGate",
|
||||||
}
|
) { shown ->
|
||||||
Crossfade(targetState = onboardingDone, animationSpec = gateSpec, label = "onboardingGate") { done ->
|
when (shown) {
|
||||||
when (done) {
|
RootTarget.Loading -> Unit
|
||||||
true -> CalendarHost(
|
RootTarget.App -> CalendarHost(
|
||||||
modifier = modifier,
|
|
||||||
requestedDetailKey = requestedDetailKey,
|
|
||||||
onDetailKeyConsumed = onDetailKeyConsumed,
|
|
||||||
widgetNavRequest = widgetNavRequest,
|
|
||||||
onWidgetNavConsumed = onWidgetNavConsumed,
|
|
||||||
requestedImportUri = requestedImportUri,
|
|
||||||
onImportConsumed = onImportConsumed,
|
|
||||||
requestedInsertForm = requestedInsertForm,
|
|
||||||
onInsertConsumed = onInsertConsumed,
|
|
||||||
requestedEditKey = requestedEditKey,
|
|
||||||
onEditKeyConsumed = onEditKeyConsumed,
|
|
||||||
)
|
|
||||||
false -> ReminderOnboardingScreen(
|
|
||||||
onFinished = reminderOnboarding::finish,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
null -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
PermissionScreen(
|
|
||||||
onGranted = { hasPermission = true },
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
requestedDetailKey = requestedDetailKey,
|
||||||
|
onDetailKeyConsumed = onDetailKeyConsumed,
|
||||||
|
widgetNavRequest = widgetNavRequest,
|
||||||
|
onWidgetNavConsumed = onWidgetNavConsumed,
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = onImportConsumed,
|
||||||
|
requestedInsertForm = requestedInsertForm,
|
||||||
|
requestedInsertSource = requestedInsertSource,
|
||||||
|
onInsertConsumed = onInsertConsumed,
|
||||||
|
requestedEditKey = requestedEditKey,
|
||||||
|
onEditKeyConsumed = onEditKeyConsumed,
|
||||||
)
|
)
|
||||||
|
// Both halves of a transition read their position out of the live
|
||||||
|
// plan; the one the outgoing step was drawn with is already gone.
|
||||||
|
is RootTarget.Step -> current?.let { plan ->
|
||||||
|
OnboardingSteps(
|
||||||
|
step = shown.step,
|
||||||
|
plan = plan,
|
||||||
|
viewModel = onboarding,
|
||||||
|
onPermissionGranted = { hasPermission = true },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,16 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
@@ -128,8 +131,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 = title,
|
title = declinedTitle(title, event.isDeclined),
|
||||||
summary = agendaTimeSummary(event, day, zone),
|
summary = AnnotatedString(agendaTimeSummary(event, day, zone)),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
leading = {
|
leading = {
|
||||||
@@ -137,7 +140,7 @@ internal fun AgendaEventRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 6.dp, height = 36.dp)
|
.size(width = 6.dp, height = 36.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(eventFill(event.color, dark, soften)),
|
.background(eventAccent(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ fun AgendaScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = anchor,
|
currentDate = anchor,
|
||||||
|
drawerState = drawerState,
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -275,7 +276,7 @@ private fun AgendaRangeBanner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaContent(
|
internal fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
pastDisplay: PastEventDisplay,
|
pastDisplay: PastEventDisplay,
|
||||||
showToday: Boolean,
|
showToday: Boolean,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sampleAgendaEvents
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/** How far ahead the agenda preview's stand-in window runs. */
|
||||||
|
private const val PREVIEW_WINDOW_DAYS = 14
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live, scaled-down Agenda view for the onboarding view chooser. Today and
|
||||||
|
* the day headers are real; only the events are stand-ins.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AgendaViewPreview(
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
val state = remember(today, zone) { sampleAgendaState(today, zone) }
|
||||||
|
|
||||||
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
|
AgendaContent(
|
||||||
|
state = state,
|
||||||
|
// Stand-in events are all upcoming, so the past-event preference
|
||||||
|
// cannot change what the preview shows — keep it at the default.
|
||||||
|
pastDisplay = PastEventDisplay.SHOW,
|
||||||
|
showToday = true,
|
||||||
|
onRetry = {},
|
||||||
|
onEventClick = {},
|
||||||
|
onOpenDay = {},
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample agenda state, grouped through the same helper the live view uses. */
|
||||||
|
private fun sampleAgendaState(today: LocalDate, zone: TimeZone): AgendaUiState.Success {
|
||||||
|
val windowEnd = today.plus(PREVIEW_WINDOW_DAYS, DateTimeUnit.DAY)
|
||||||
|
return AgendaUiState.Success(
|
||||||
|
anchor = today,
|
||||||
|
today = today,
|
||||||
|
days = groupAgendaDays(today, windowEnd, sampleAgendaEvents(today, zone), zone),
|
||||||
|
range = AgendaRange.Custom(PREVIEW_WINDOW_DAYS),
|
||||||
|
rangeIsOverride = false,
|
||||||
|
rangeEnd = windowEnd,
|
||||||
|
// The range bar belongs to the screen, not the list — the preview shows
|
||||||
|
// the list only.
|
||||||
|
showRangeBar = false,
|
||||||
|
zone = zone,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one-time notice that Calendula now follows the device's per-calendar
|
|
||||||
* visibility (#75), armed by `CalendarVisibilityReconciler`. The app does not
|
|
||||||
* switch those calendars back on — that would un-hide them everywhere else too.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class CalendarVisibilityNoticeViewModel @Inject constructor(
|
|
||||||
private val prefs: CalendarPrefs,
|
|
||||||
private val reconciler: CalendarVisibilityReconciler,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconcile whenever the app comes up with the calendar permission held,
|
|
||||||
* rather than off one grant route: a permission granted on Android's
|
|
||||||
* app-settings screen never reaches the permission screen's callback.
|
|
||||||
*/
|
|
||||||
fun reconcile() {
|
|
||||||
viewModelScope.launch { reconciler.run() }
|
|
||||||
}
|
|
||||||
|
|
||||||
val pending: StateFlow<Boolean> = prefs.visibilityNoticePending
|
|
||||||
.map { it == true }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun dismiss() {
|
|
||||||
viewModelScope.launch { prefs.setVisibilityNoticePending(false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Plain informational dialog — one acknowledgement, nothing to decide. */
|
|
||||||
@Composable
|
|
||||||
fun CalendarVisibilityNoticeDialog(onDismiss: () -> Unit) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
icon = { Icon(Icons.Default.VisibilityOff, contentDescription = null) },
|
|
||||||
title = { Text(stringResource(R.string.calendars_visibility_notice_title)) },
|
|
||||||
text = { Text(stringResource(R.string.calendars_visibility_notice_message)) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.dialog_ok))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -88,6 +88,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
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.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||||
@@ -442,7 +443,7 @@ private fun CalendarEditor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
|
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||||
|
snap()
|
||||||
|
} else {
|
||||||
|
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
|
}
|
||||||
|
Crossfade(
|
||||||
|
targetState = label,
|
||||||
|
animationSpec = spec,
|
||||||
|
label = "block-time",
|
||||||
|
modifier = modifier,
|
||||||
|
) { text ->
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a timed block sits in its column, after tweening. */
|
||||||
|
@Immutable
|
||||||
|
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
|
||||||
|
* the caller keying each block by identity; a block composed for the first time
|
||||||
|
* starts at its target, so nothing flies in on the first frame.
|
||||||
|
*
|
||||||
|
* A pinch-zoom rewrites the hour height every pointer frame, and the gutter and
|
||||||
|
* grid lines follow it instantly — so the tween stands down for the gesture
|
||||||
|
* rather than leaving the blocks trailing the ruler they are measured against.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
||||||
|
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion() ||
|
||||||
|
LocalTimelineZoom.current.isPinching
|
||||||
|
) {
|
||||||
|
snap()
|
||||||
|
} else {
|
||||||
|
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
|||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.CalendarMonth,
|
Icons.Filled.CalendarMonth,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = eventFill(color, dark, soften),
|
tint = eventAccent(color, dark, soften),
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.DateRange
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.DrawerState
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
@@ -27,19 +29,23 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +56,8 @@ import kotlinx.datetime.LocalDate
|
|||||||
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||||
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||||
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||||
* The host screen owns the drawer state.
|
* The host screen owns the drawer state; the sheet reads it only to dismiss
|
||||||
|
* itself on back.
|
||||||
*
|
*
|
||||||
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||||
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||||
@@ -59,12 +66,17 @@ import kotlinx.datetime.LocalDate
|
|||||||
fun CalendarDrawer(
|
fun CalendarDrawer(
|
||||||
currentView: CalendarView,
|
currentView: CalendarView,
|
||||||
currentDate: LocalDate,
|
currentDate: LocalDate,
|
||||||
|
drawerState: DrawerState,
|
||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
) {
|
) {
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Registered in the sheet so it takes precedence over the host's back handler.
|
||||||
|
BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } }
|
||||||
|
|
||||||
ModalDrawerSheet {
|
ModalDrawerSheet {
|
||||||
// The whole sidebar scrolls as one — header, views, the calendar filter
|
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||||
@@ -83,7 +95,7 @@ fun CalendarDrawer(
|
|||||||
position = positionOf(index, viewOrder.size),
|
position = positionOf(index, viewOrder.size),
|
||||||
selected = view == currentView,
|
selected = view == currentView,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { Icon(view.icon, contentDescription = null) },
|
leading = { DrawerLeadingIcon(view.icon) },
|
||||||
onClick = { onSelectView(view) },
|
onClick = { onSelectView(view) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -93,7 +105,7 @@ fun CalendarDrawer(
|
|||||||
title = stringResource(R.string.drawer_jump_to_date),
|
title = stringResource(R.string.drawer_jump_to_date),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
|
||||||
onClick = { showDatePicker = true },
|
onClick = { showDatePicker = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,7 +119,7 @@ fun CalendarDrawer(
|
|||||||
title = stringResource(R.string.month_action_settings),
|
title = stringResource(R.string.month_action_settings),
|
||||||
position = Position.Alone,
|
position = Position.Alone,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
leading = { DrawerLeadingIcon(Icons.Filled.Settings) },
|
||||||
onClick = onSettings,
|
onClick = onSettings,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
@@ -126,13 +138,27 @@ fun CalendarDrawer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
|
||||||
|
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
|
||||||
|
@Composable
|
||||||
|
private fun DrawerLeadingIcon(icon: ImageVector) {
|
||||||
|
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Icon(icon, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Branded header: the app-icon chip beside the app name. */
|
/** Branded header: the app-icon chip beside the app name. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun DrawerHeader() {
|
private fun DrawerHeader() {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
|
.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 24.dp,
|
||||||
|
bottom = 16.dp,
|
||||||
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -164,6 +190,11 @@ private fun DrawerSectionHeader(text: String) {
|
|||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 16.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-bar title that doubles as the jump-to-date entry point: the formatted
|
||||||
|
* [title] with a drop-down caret, opening the same [CalendarDatePickerDialog] the
|
||||||
|
* drawer uses and reporting the chosen day through [onJumpToDate].
|
||||||
|
*
|
||||||
|
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||||
|
* visible day, week start or month anchor).
|
||||||
|
*
|
||||||
|
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
|
||||||
|
* start alignment: M3 places the title flush against the navigation icon's
|
||||||
|
* 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
|
||||||
|
fun CalendarTitleButton(
|
||||||
|
title: String,
|
||||||
|
currentDate: LocalDate,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
|
.clip(MaterialTheme.shapes.large)
|
||||||
|
.clickable(
|
||||||
|
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||||
|
role = Role.Button,
|
||||||
|
) { showDatePicker = true }
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDatePicker) {
|
||||||
|
CalendarDatePickerDialog(
|
||||||
|
initial = currentDate,
|
||||||
|
onConfirm = {
|
||||||
|
showDatePicker = false
|
||||||
|
onJumpToDate(it)
|
||||||
|
},
|
||||||
|
onDismiss = { showDatePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,45 +2,53 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.luminance
|
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.domain.color.inkFor
|
||||||
|
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether calendar/event colours are softened toward theme-fitting pastels
|
* Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
|
||||||
* before display (issue #36). Provided app-wide from the "soften colours"
|
* per role — before display (issue #36). Provided app-wide from the setting; the
|
||||||
* setting; the default `true` keeps the historical look. When off, the raw
|
* default `true` is the app's own look. When off, the raw provider colour is
|
||||||
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
* painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
|
||||||
* and other calendar apps. Widgets live outside this composition and read the
|
* apps. Widgets live outside this composition and read the preference directly,
|
||||||
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
* then pass the flag through.
|
||||||
*/
|
*/
|
||||||
val LocalSoftenColors = staticCompositionLocalOf { true }
|
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
* Fill for a surface that carries text on it — a week/day block, a month bar, a
|
||||||
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
* widget row, a picker swatch. Pair it with [eventInk].
|
||||||
* pastelize also returns an opaque colour).
|
|
||||||
*/
|
*/
|
||||||
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
Color(eventTone(rawArgb, dark, soften).container)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
* Colour for a mark on an ordinary app surface — a day dot, an agenda or search
|
||||||
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
* stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
|
||||||
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
* pinned to contrast with the surface instead of with ink.
|
||||||
* perceptually dark even after softening pins its HSV value, so black text on it
|
|
||||||
* reads poorly. The choice is objective, not a tuned threshold: white wins only
|
|
||||||
* when it out-contrasts black against the fill, which (by the WCAG contrast
|
|
||||||
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
|
|
||||||
* near-black text. [alpha] carries the caller's soft emphasis.
|
|
||||||
*/
|
*/
|
||||||
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
|
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||||
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
Color(eventTone(rawArgb, dark, soften).accent)
|
||||||
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relative-luminance crossover where white text starts to out-contrast black.
|
* Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
|
||||||
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
*
|
||||||
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
* Takes the fill rather than the raw colour so it stays correct for the raw
|
||||||
|
* (un-harmonised) path too, where the provider decides the lightness.
|
||||||
*/
|
*/
|
||||||
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
fun eventInk(fill: Color, alpha: Float = 0.8f): Color =
|
||||||
|
Color(inkFor(oklchOf(fill.toArgbInt()).lightness)).copy(alpha = alpha)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ink alpha for a block's secondary line (the time under the title). Held above
|
||||||
|
* a fainter value because harmonised containers are deep: the small time text
|
||||||
|
* needs to stay clear of the 4.5:1 WCAG asks of body text.
|
||||||
|
*/
|
||||||
|
const val SECONDARY_INK_ALPHA = 0.8f
|
||||||
|
|
||||||
|
private fun Color.toArgbInt(): Int =
|
||||||
|
(0xFF shl 24) or
|
||||||
|
((red * 255f).toInt().coerceIn(0, 255) shl 16) or
|
||||||
|
((green * 255f).toInt().coerceIn(0, 255) shl 8) or
|
||||||
|
(blue * 255f).toInt().coerceIn(0, 255)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** How far the finger may wander during the hold — well under touch slop. */
|
||||||
|
private val PICKUP_TOLERANCE = 6.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick an event block up with a long press and drag it, without
|
||||||
|
* `detectDragGesturesAfterLongPress`: the stock detector cancels as soon as an
|
||||||
|
* ancestor consumes or the finger leaves the block, which a
|
||||||
|
* `MIN_EVENT_FRACTION`-tall block loses immediately. Movement before the timeout
|
||||||
|
* is deliberately not consumed, so a scroll starting on top of a block survives.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberEventDragSource(
|
||||||
|
enabled: Boolean,
|
||||||
|
key: Any?,
|
||||||
|
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
||||||
|
onMove: (pointerInRoot: Offset) -> Unit,
|
||||||
|
onDrop: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
): Modifier = rememberDragSurface(
|
||||||
|
enabled = enabled,
|
||||||
|
key = key,
|
||||||
|
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
||||||
|
onPickUp(pointerInRoot, nodeInRoot, size)
|
||||||
|
true
|
||||||
|
},
|
||||||
|
onMove = onMove,
|
||||||
|
onDrop = onDrop,
|
||||||
|
onCancel = onCancel,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same pickup, for a surface that carries many draggable pieces rather than
|
||||||
|
* being one itself — the month grid, whose chips are covered by a full-bleed tap
|
||||||
|
* layer. [onPickUp] receives the press position local to this node and answers
|
||||||
|
* whether anything is there; false abandons the gesture.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberDragSurface(
|
||||||
|
enabled: Boolean,
|
||||||
|
key: Any?,
|
||||||
|
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> Boolean,
|
||||||
|
onMove: (pointerInRoot: Offset) -> Unit,
|
||||||
|
onDrop: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
): Modifier {
|
||||||
|
if (!enabled) return Modifier
|
||||||
|
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||||
|
val tolerance = with(LocalDensity.current) { PICKUP_TOLERANCE.toPx() }
|
||||||
|
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||||
|
val currentPickUp by rememberUpdatedState(onPickUp)
|
||||||
|
val currentMove by rememberUpdatedState(onMove)
|
||||||
|
val currentDrop by rememberUpdatedState(onDrop)
|
||||||
|
val currentCancel by rememberUpdatedState(onCancel)
|
||||||
|
|
||||||
|
return Modifier
|
||||||
|
.onGloballyPositioned { coordinates[0] = it }
|
||||||
|
.pointerInput(key) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
val origin = down.position
|
||||||
|
val heldStill = withTimeoutOrNull(holdMillis) {
|
||||||
|
while (true) {
|
||||||
|
val main = awaitPointerEvent()
|
||||||
|
val change = main.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
if (!change.pressed) break
|
||||||
|
if (main.changes.count { it.pressed } > 1) break
|
||||||
|
if ((change.position - origin).getDistance() > tolerance) break
|
||||||
|
// An ancestor's claim only becomes visible once the whole
|
||||||
|
// main pass has run, so look again on the final pass.
|
||||||
|
val final = awaitPointerEvent(PointerEventPass.Final)
|
||||||
|
if (final.changes.any { it.isConsumed }) break
|
||||||
|
}
|
||||||
|
} == null
|
||||||
|
if (!heldStill) return@awaitEachGesture
|
||||||
|
|
||||||
|
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
||||||
|
val took = currentPickUp(
|
||||||
|
down.position,
|
||||||
|
layout.localToRoot(down.position),
|
||||||
|
layout.positionInRoot(),
|
||||||
|
layout.size,
|
||||||
|
)
|
||||||
|
if (!took) return@awaitEachGesture
|
||||||
|
var dropped = false
|
||||||
|
try {
|
||||||
|
// Driven on the initial pass, which runs parent → child: an
|
||||||
|
// ancestor that outranks us (the pinch) has already consumed
|
||||||
|
// by the time we look, and descendants — the month grid's
|
||||||
|
// full-bleed tap layer — see our consumption.
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
if (change.isConsumed) break
|
||||||
|
if (!change.pressed) {
|
||||||
|
change.consume()
|
||||||
|
dropped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (event.changes.count { it.pressed } > 1) break
|
||||||
|
change.consume()
|
||||||
|
coordinates[0]?.takeIf { it.isAttached }
|
||||||
|
?.let { currentMove(it.localToRoot(change.position)) }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Also reached when the pointer node is disposed mid-drag —
|
||||||
|
// a cancel, not a drop, and must never write.
|
||||||
|
if (dropped) currentDrop() else currentCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lift on pickup, then a tick every time the drop target snaps to a new slot. */
|
||||||
|
@Composable
|
||||||
|
fun DragSnapHaptics(slot: Any?) {
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val previous = remember { arrayOfNulls<Any>(1) }
|
||||||
|
LaunchedEffect(slot) {
|
||||||
|
val had = previous[0]
|
||||||
|
previous[0] = slot
|
||||||
|
when {
|
||||||
|
slot == null -> Unit
|
||||||
|
had == null -> haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
had != slot -> haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChip
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** How long the confirmation chip stays up, matching a short snackbar. */
|
||||||
|
private const val CHIP_MILLIS = 4_000L
|
||||||
|
|
||||||
|
/** How long an *undone* move stays up — shorter, since it offers nothing to act on. */
|
||||||
|
private const val UNDONE_CHIP_MILLIS = 1_600L
|
||||||
|
|
||||||
|
/** What the chip currently reads, kept past the outcome it was built from. */
|
||||||
|
private data class ChipContent(val message: String, val undo: MoveUndo?)
|
||||||
|
|
||||||
|
/** The FAB's own band at the bottom end, which the chip must not run into. */
|
||||||
|
private val FAB_BAND = 88.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
|
||||||
|
* recurring-scope prompt, and the confirmation chip carrying Undo. None of the
|
||||||
|
* calendar screens sets a `snackbarHost`, so this hosts its own confirmation as
|
||||||
|
* a pill on the FAB's band, leaving the calendar visible behind it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||||
|
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||||
|
val undoTick by viewModel.undoStarted.collectAsStateWithLifecycle()
|
||||||
|
val writeInFlight by viewModel.inFlight.collectAsStateWithLifecycle()
|
||||||
|
val locale = currentLocale()
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
|
||||||
|
prompt?.let { pending ->
|
||||||
|
RecurringScopeDialog(
|
||||||
|
title = stringResource(R.string.event_move_recurring_title),
|
||||||
|
onSelect = viewModel::moveWithScope,
|
||||||
|
onDismiss = viewModel::cancelScope,
|
||||||
|
allowSeries = !pending.occurrenceOnly,
|
||||||
|
reason = stringResource(R.string.event_move_occurrence_only)
|
||||||
|
.takeIf { pending.occurrenceOnly },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val moved = outcome as? MoveOutcome.Moved
|
||||||
|
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
|
||||||
|
val message = when (outcome) {
|
||||||
|
null -> null
|
||||||
|
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
||||||
|
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
||||||
|
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||||
|
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||||
|
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
|
||||||
|
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held past the outcome being consumed so the chip has something to draw
|
||||||
|
// while it springs back out. Updated in composition rather than from an
|
||||||
|
// effect, which would land a frame late and open the chip on stale text.
|
||||||
|
val shown = remember { mutableStateOf(ChipContent("", null)) }
|
||||||
|
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
|
||||||
|
shown.value = ChipContent(message, moved?.undo)
|
||||||
|
}
|
||||||
|
val content = shown.value
|
||||||
|
// Restarted by the undo tick and held while a write is in flight: an undo
|
||||||
|
// tapped in the last moments of the window leaves the outcome at Moved on
|
||||||
|
// purpose, and this timer would otherwise fire mid-undo and close the chip
|
||||||
|
// just before the same chip has to say "undone".
|
||||||
|
LaunchedEffect(outcome, undoTick, writeInFlight) {
|
||||||
|
if (outcome == null || writeInFlight) return@LaunchedEffect
|
||||||
|
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
||||||
|
viewModel.consumeOutcome()
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
|
val chipMaxWidth = maxWidth - FAB_BAND
|
||||||
|
// A FAB-height band at the bottom start with the FAB's own margin, so
|
||||||
|
// the chip lines up beside the bottom-end FAB.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||||
|
.height(SnackChipHeight),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
// The action goes away while a write runs: undo() refuses a second
|
||||||
|
// write anyway, so offering it would be a button that does nothing.
|
||||||
|
val undo = content.undo?.takeIf { !writeInFlight }
|
||||||
|
SnackChip(
|
||||||
|
visible = outcome != null,
|
||||||
|
message = content.message,
|
||||||
|
maxWidth = chipMaxWidth,
|
||||||
|
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||||
|
onAction = undo?.let { { viewModel.undo(it) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events
|
||||||
|
* are read back on the UTC calendar day they are anchored to (#65, #82).
|
||||||
|
*/
|
||||||
|
private fun formatMovedTo(
|
||||||
|
startMillis: Long,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
use24Hour: Boolean,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
val zone: ZoneId = if (isAllDay) ZoneOffset.UTC else ZoneId.systemDefault()
|
||||||
|
val skeleton = when {
|
||||||
|
isAllDay -> "EEEdMMM"
|
||||||
|
use24Hour -> "EEEdMMMHm"
|
||||||
|
else -> "EEEdMMMhm"
|
||||||
|
}
|
||||||
|
return localizedDateFormatter(locale, skeleton)
|
||||||
|
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
|
||||||
|
* whichever event block is being composed. Null means moving is off entirely and
|
||||||
|
* blocks register no drag gesture at all.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class EventMoveScope(
|
||||||
|
/**
|
||||||
|
* Calendars whose events may be moved. Nothing below the UI enforces this —
|
||||||
|
* the repository writes whatever it is handed — so this gate is load-bearing.
|
||||||
|
*/
|
||||||
|
val movableCalendarIds: Set<Long>,
|
||||||
|
/**
|
||||||
|
* Whether the *gesture* is on (#173). Off gates the drag alone: [edit] and the
|
||||||
|
* TalkBack action below stay, since invoking a menu item can't cause the
|
||||||
|
* accidental drags the setting exists to stop.
|
||||||
|
*/
|
||||||
|
val dragEnabled: Boolean,
|
||||||
|
/** False when the drop was refused outright, so nothing will be written. */
|
||||||
|
val move: (MoveRequest) -> Boolean,
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* object across a move — every visible block reads it as a composition local.
|
||||||
|
*/
|
||||||
|
val inFlight: StateFlow<Boolean>,
|
||||||
|
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||||
|
val undoStarted: StateFlow<Int>,
|
||||||
|
/** Open an event in the edit form — the pointer-free route to the same change. */
|
||||||
|
val edit: (EventInstance) -> Unit,
|
||||||
|
) {
|
||||||
|
fun allows(event: EventInstance): Boolean = event.calendarId in movableCalendarIds
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
|
||||||
|
|
||||||
|
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
||||||
|
|
||||||
|
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun moveInFlight(): Boolean {
|
||||||
|
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
|
||||||
|
return flow.collectAsStateWithLifecycle().value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [onUndo] when an undo write begins, and never for one that began before
|
||||||
|
* this composable came on screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OnUndoStarted(onUndo: () -> Unit) {
|
||||||
|
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
|
||||||
|
val tick by flow.collectAsStateWithLifecycle()
|
||||||
|
var seen by remember { mutableIntStateOf(tick) }
|
||||||
|
LaunchedEffect(tick) {
|
||||||
|
if (tick == seen) return@LaunchedEffect
|
||||||
|
seen = tick
|
||||||
|
onUndo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opacity the source block keeps while its floating copy travels. */
|
||||||
|
const val GHOST_ALPHA: Float = 0.3f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opacity for a block whose copy is in flight: ghosted from the lift until the
|
||||||
|
* copy is handed back, then animated up rather than switched, so the block
|
||||||
|
* appears to travel to its new slot instead of vanishing and reappearing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
|
||||||
|
targetValue = if (lifted) GHOST_ALPHA else 1f,
|
||||||
|
label = "ghost-alpha",
|
||||||
|
).value
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [event] can be picked up and dragged — its calendar allows moving and
|
||||||
|
* the gesture is switched on. Not the same question as [eventMoveAction], which
|
||||||
|
* survives the setting.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun eventDragAllowed(event: EventInstance): Boolean {
|
||||||
|
val move = LocalEventMove.current ?: return false
|
||||||
|
return move.dragEnabled && move.allows(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
||||||
|
* pointer-only. Null when this event can't be moved.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? {
|
||||||
|
val move = LocalEventMove.current ?: return null
|
||||||
|
if (!move.allows(event)) return null
|
||||||
|
val label = stringResource(R.string.event_move_action)
|
||||||
|
return remember(event.instanceId, label, move) {
|
||||||
|
CustomAccessibilityAction(label) {
|
||||||
|
move.edit(event)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
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.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
|
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
||||||
|
val GUTTER_WIDTH = 48.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||||
|
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp,
|
||||||
|
* matching the icon button's centre.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|
||||||
|
/** How far the fixed hour labels recede while a block is being dragged. */
|
||||||
|
private const val DIMMED_HOUR_ALPHA = 0.3f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeline's hour gutter. Scrolls in sync with the day columns through the
|
||||||
|
* shared [scrollState], and while [dragController] holds a lifted block it dims
|
||||||
|
* the hour labels and floats a badge with the drag's current start time at the
|
||||||
|
* row the block would land on.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HourGutter(
|
||||||
|
scrollState: ScrollState,
|
||||||
|
hourHeight: Dp,
|
||||||
|
dragController: TimelineDragController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
// Derived: the drag is rewritten every frame, its snapped start once a slot.
|
||||||
|
val dragStartMin by remember(dragController) {
|
||||||
|
derivedStateOf { dragController.drag?.startMin }
|
||||||
|
}
|
||||||
|
val hourAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||||
|
label = "hourLabelAlpha",
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.width(GUTTER_WIDTH)
|
||||||
|
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
(0 until 24).forEach { h ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(hourHeight),
|
||||||
|
) {
|
||||||
|
if (h > 0) {
|
||||||
|
Text(
|
||||||
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
.copy(alpha = hourAlpha),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = (-6).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragStartMin?.let { startMin ->
|
||||||
|
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = top)
|
||||||
|
.height(BADGE_HEIGHT)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
// The dragged block already announces this time.
|
||||||
|
.clearAndSetSemantics { },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatGutterTime(startMin, use24Hour, locale),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,3 +18,30 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -354,35 +354,66 @@ private fun CustomDaysEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
* Duration picker, full-screen and **single-select**: the [presets] (whole-minute
|
||||||
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
* lengths) each sit as a checkmark row, with a "Custom" row that expands an
|
||||||
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
* inline amount field plus a Minutes/Hours unit toggle to enter an arbitrary
|
||||||
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
* one. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking a preset or
|
||||||
* a preset or confirming a custom value applies via [onSelect] and closes.
|
* confirming a custom value applies via [onSelect] and closes. [label] renders a
|
||||||
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
* length in minutes ("10 minutes", "1 hour") and is reused for both the rows and
|
||||||
* is reused for both the rows and the custom preview.
|
* the custom preview.
|
||||||
|
*
|
||||||
|
* [inheritLabel] adds an exclusive "use the default" row on top, for the
|
||||||
|
* per-calendar pickers; picking it reports null. [selected] is null exactly when
|
||||||
|
* that row is the current choice. [description] explains the setting above the
|
||||||
|
* rows when it needs it. [maxMinutes] caps what the custom editor will confirm,
|
||||||
|
* for settings that clamp their stored value — offering a length the store would
|
||||||
|
* silently shorten is worse than refusing it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SnoozeDurationPicker(
|
fun DurationPicker(
|
||||||
title: String,
|
title: String,
|
||||||
presets: List<Int>,
|
presets: List<Int>,
|
||||||
selected: Int,
|
selected: Int?,
|
||||||
label: @Composable (Int) -> String,
|
label: @Composable (Int) -> String,
|
||||||
onSelect: (Int) -> Unit,
|
onSelect: (Int?) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
description: String? = null,
|
||||||
|
inheritLabel: String? = null,
|
||||||
|
maxMinutes: Int = Int.MAX_VALUE,
|
||||||
) {
|
) {
|
||||||
val customSelected = selected !in presets
|
val inherits = selected == null
|
||||||
|
// The current choice when it isn't one of the presets — the Custom row then
|
||||||
|
// names it and the editor opens pre-filled with it.
|
||||||
|
val custom = selected?.takeIf { it !in presets }
|
||||||
val rowCount = presets.size + 1 // + the custom row
|
val rowCount = presets.size + 1 // + the custom row
|
||||||
|
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable {
|
var amountText by rememberSaveable {
|
||||||
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
mutableStateOf(custom?.let { durationCustomAmount(it).toString() }.orEmpty())
|
||||||
}
|
}
|
||||||
var unit by rememberSaveable {
|
var unit by rememberSaveable {
|
||||||
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
mutableStateOf(custom?.let { durationCustomUnit(it) } ?: ReminderUnit.Minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||||
|
if (description != null) PickerDescription(description)
|
||||||
|
if (inheritLabel != null) {
|
||||||
|
GroupedRow(
|
||||||
|
title = inheritLabel,
|
||||||
|
position = Position.Alone,
|
||||||
|
selected = inherits,
|
||||||
|
trailing = if (inherits) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(null)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
presets.forEachIndexed { index, minute ->
|
presets.forEachIndexed { index, minute ->
|
||||||
val isSelected = minute == selected
|
val isSelected = minute == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -403,10 +434,10 @@ fun SnoozeDurationPicker(
|
|||||||
// The Custom row connects downward into the editor card when expanded, so
|
// The Custom row connects downward into the editor card when expanded, so
|
||||||
// the two read as one grouped container (the shared custom-expand pattern).
|
// the two read as one grouped container (the shared custom-expand pattern).
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
|
||||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||||
selected = customSelected,
|
selected = custom != null,
|
||||||
trailing = if (customSelected) {
|
trailing = if (custom != null) {
|
||||||
{ SelectedCheck() }
|
{ SelectedCheck() }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
@@ -418,12 +449,13 @@ fun SnoozeDurationPicker(
|
|||||||
enter = expandEnter(),
|
enter = expandEnter(),
|
||||||
exit = collapseExit(),
|
exit = collapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomSnoozeEditor(
|
CustomDurationEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
onAmountChange = { amountText = it },
|
onAmountChange = { amountText = it },
|
||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
label = label,
|
label = label,
|
||||||
|
maxMinutes = maxMinutes,
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(minutes)
|
onSelect(minutes)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
@@ -433,41 +465,48 @@ fun SnoozeDurationPicker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whole hours if the delay divides evenly, else minutes. */
|
/** Whole hours if the length divides evenly, else minutes. */
|
||||||
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
private fun durationCustomUnit(minutes: Int): ReminderUnit =
|
||||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||||
|
|
||||||
private fun snoozeCustomAmount(minutes: Int): Int =
|
private fun durationCustomAmount(minutes: Int): Int =
|
||||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
* The expanded "Custom" duration editor: a tonal card connected to the Custom row
|
||||||
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||||
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
* the length it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||||
* amount. [onConfirm] receives the final delay in minutes.
|
* amount that stays within [maxMinutes]. Over the cap, the preview names the
|
||||||
|
* limit instead of a length the setting could not keep. [onConfirm] receives the
|
||||||
|
* final length in minutes.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CustomSnoozeEditor(
|
private fun CustomDurationEditor(
|
||||||
amountText: String,
|
amountText: String,
|
||||||
onAmountChange: (String) -> Unit,
|
onAmountChange: (String) -> Unit,
|
||||||
unit: ReminderUnit,
|
unit: ReminderUnit,
|
||||||
onUnitChange: (ReminderUnit) -> Unit,
|
onUnitChange: (ReminderUnit) -> Unit,
|
||||||
label: @Composable (Int) -> String,
|
label: @Composable (Int) -> String,
|
||||||
|
maxMinutes: Int,
|
||||||
onConfirm: (Int) -> Unit,
|
onConfirm: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
||||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
val entered = amountText.toIntOrNull()?.takeIf { it in 1..999 }?.times(unit.minutesFactor)
|
||||||
|
val amount = entered?.takeIf { it <= maxMinutes }
|
||||||
CustomAmountEditor(
|
CustomAmountEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
onAmountChange = onAmountChange,
|
onAmountChange = onAmountChange,
|
||||||
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||||
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
||||||
onUnitChange = { onUnitChange(units[it]) },
|
onUnitChange = { onUnitChange(units[it]) },
|
||||||
preview = amount?.let { label(it * unit.minutesFactor) }
|
preview = when {
|
||||||
?: stringResource(R.string.reminder_custom_amount),
|
amount != null -> label(amount)
|
||||||
|
entered != null -> stringResource(R.string.duration_custom_max, label(maxMinutes))
|
||||||
|
else -> stringResource(R.string.reminder_custom_amount)
|
||||||
|
},
|
||||||
setLabel = stringResource(R.string.reminder_custom_set),
|
setLabel = stringResource(R.string.reminder_custom_set),
|
||||||
confirmEnabled = amount != null,
|
confirmEnabled = amount != null,
|
||||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
onConfirm = { amount?.let(onConfirm) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atTime
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in events for the view previews. A preview has no business querying the
|
||||||
|
* provider for a thumbnail, so every chooser renders the same invented week.
|
||||||
|
*
|
||||||
|
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||||
|
* event, so a theme token here would misrepresent what the views actually draw.
|
||||||
|
*/
|
||||||
|
private val SAMPLE_COLORS = listOf(
|
||||||
|
0xFF3F7BD4.toInt(),
|
||||||
|
0xFFCE5B4C.toInt(),
|
||||||
|
0xFF4E9A6A.toInt(),
|
||||||
|
0xFF8A63C7.toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val SAMPLE_TITLES = listOf(
|
||||||
|
"Standup",
|
||||||
|
"Lunch",
|
||||||
|
"Review",
|
||||||
|
"Gym",
|
||||||
|
"Call",
|
||||||
|
"Workshop",
|
||||||
|
"Dentist",
|
||||||
|
"Trip",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Hands out [EventInstance]s with running ids and cycling sample titles/colours. */
|
||||||
|
private class SampleEvents(private val zone: TimeZone) {
|
||||||
|
private var id = 0L
|
||||||
|
|
||||||
|
fun timed(
|
||||||
|
day: LocalDate,
|
||||||
|
hour: Int,
|
||||||
|
minute: Int = 0,
|
||||||
|
lengthMinutes: Int,
|
||||||
|
colorIndex: Int,
|
||||||
|
): EventInstance {
|
||||||
|
val start = day.atTime(hour, minute).toInstant(zone)
|
||||||
|
return build(
|
||||||
|
start = start,
|
||||||
|
end = start + lengthMinutes.minutes,
|
||||||
|
isAllDay = false,
|
||||||
|
colorIndex = colorIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun allDay(from: LocalDate, days: Int, colorIndex: Int): EventInstance = build(
|
||||||
|
// All-day events sit at UTC midnights with an exclusive end.
|
||||||
|
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||||
|
isAllDay = true,
|
||||||
|
colorIndex = colorIndex,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun build(
|
||||||
|
start: Instant,
|
||||||
|
end: Instant,
|
||||||
|
isAllDay: Boolean,
|
||||||
|
colorIndex: Int,
|
||||||
|
): EventInstance {
|
||||||
|
val next = ++id
|
||||||
|
return EventInstance(
|
||||||
|
instanceId = next,
|
||||||
|
eventId = next,
|
||||||
|
calendarId = 1L,
|
||||||
|
title = SAMPLE_TITLES[(next.toInt() - 1) % SAMPLE_TITLES.size],
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
isAllDay = isAllDay,
|
||||||
|
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||||
|
location = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A month's worth of stand-ins, spread so most weeks carry something, with one
|
||||||
|
* multi-day bar bridging cells and a busy today for the split pane.
|
||||||
|
*/
|
||||||
|
internal fun sampleMonthEvents(
|
||||||
|
firstOfMonth: LocalDate,
|
||||||
|
today: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<EventInstance> {
|
||||||
|
val sample = SampleEvents(zone)
|
||||||
|
return buildList {
|
||||||
|
add(sample.allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||||
|
add(sample.timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, lengthMinutes = 120, colorIndex = 1))
|
||||||
|
add(sample.timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
||||||
|
add(sample.timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, lengthMinutes = 60, colorIndex = 2))
|
||||||
|
add(sample.timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, lengthMinutes = 120, colorIndex = 1))
|
||||||
|
add(sample.timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, lengthMinutes = 60, colorIndex = 3))
|
||||||
|
// Today, so the split pane's list and the grid's dots both have content.
|
||||||
|
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(today, 12, lengthMinutes = 60, colorIndex = 1))
|
||||||
|
add(sample.timed(today, 15, lengthMinutes = 120, colorIndex = 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-ins for a timeline preview over [days]. [anchor] — the day the preview
|
||||||
|
* is centred on — gets the busy set, including one overlapping pair so the
|
||||||
|
* side-by-side lane layout shows; the rest of the week is sparser. A multi-day
|
||||||
|
* all-day bar is only added when there is more than one column to span.
|
||||||
|
*/
|
||||||
|
internal fun sampleTimelineEvents(
|
||||||
|
days: List<LocalDate>,
|
||||||
|
anchor: LocalDate,
|
||||||
|
zone: TimeZone,
|
||||||
|
): List<EventInstance> {
|
||||||
|
val sample = SampleEvents(zone)
|
||||||
|
return buildList {
|
||||||
|
if (days.size > 1) {
|
||||||
|
add(sample.allDay(days[days.size / 2], days = 2, colorIndex = 2))
|
||||||
|
} else {
|
||||||
|
add(sample.allDay(anchor, days = 1, colorIndex = 2))
|
||||||
|
}
|
||||||
|
// The anchor day: enough to fill the column, with 09:30–11:00 running
|
||||||
|
// under 09:00–10:00 so the overlap resolves into two lanes.
|
||||||
|
add(sample.timed(anchor, 9, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(anchor, 9, minute = 30, lengthMinutes = 90, colorIndex = 1))
|
||||||
|
add(sample.timed(anchor, 13, lengthMinutes = 120, colorIndex = 3))
|
||||||
|
days.filterNot { it == anchor }.forEachIndexed { index, day ->
|
||||||
|
// Skip every third day so the week doesn't read as a solid block.
|
||||||
|
if (index % 3 == 2) return@forEachIndexed
|
||||||
|
add(
|
||||||
|
sample.timed(
|
||||||
|
day = day,
|
||||||
|
hour = 8 + (index * 3) % 9,
|
||||||
|
lengthMinutes = if (index % 2 == 0) 60 else 90,
|
||||||
|
colorIndex = index,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-ins for the agenda preview: a forward-looking fortnight where the first
|
||||||
|
* days are busy and later ones thin out, so the grouped-by-day list shows both
|
||||||
|
* a full day and the gaps between days.
|
||||||
|
*/
|
||||||
|
internal fun sampleAgendaEvents(today: LocalDate, zone: TimeZone): List<EventInstance> {
|
||||||
|
val sample = SampleEvents(zone)
|
||||||
|
return buildList {
|
||||||
|
add(sample.timed(today, 9, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(today, 12, minute = 30, lengthMinutes = 60, colorIndex = 1))
|
||||||
|
add(sample.timed(today, 16, lengthMinutes = 90, colorIndex = 3))
|
||||||
|
val tomorrow = today.plus(1, DateTimeUnit.DAY)
|
||||||
|
add(sample.allDay(tomorrow, days = 2, colorIndex = 2))
|
||||||
|
add(sample.timed(tomorrow, 10, lengthMinutes = 120, colorIndex = 1))
|
||||||
|
add(sample.timed(today.plus(3, DateTimeUnit.DAY), 14, lengthMinutes = 60, colorIndex = 0))
|
||||||
|
add(sample.timed(today.plus(6, DateTimeUnit.DAY), 11, lengthMinutes = 60, colorIndex = 3))
|
||||||
|
add(sample.timed(today.plus(10, DateTimeUnit.DAY), 18, lengthMinutes = 120, colorIndex = 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a write to a recurring event should reach: this occurrence, it and
|
||||||
|
* everything after (a series split), or the whole series. Shared by the edit
|
||||||
|
* screen's save and a drag-and-drop reschedule; one of the two carve-outs from
|
||||||
|
* the full-screen picker rule.
|
||||||
|
*
|
||||||
|
* [allowOccurrence] drops "only this event" (an exception row can't carry its own
|
||||||
|
* rule); [allowSeries] drops the two wider options, for a rule whose days can't
|
||||||
|
* be recalculated from one moved occurrence — [reason] then says why.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun RecurringScopeDialog(
|
||||||
|
title: String,
|
||||||
|
onSelect: (RecurringWriteScope) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
allowOccurrence: Boolean = true,
|
||||||
|
allowSeries: Boolean = true,
|
||||||
|
reason: String? = null,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (reason != null) {
|
||||||
|
Text(
|
||||||
|
text = reason,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (allowOccurrence) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_occurrence),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (allowSeries) {
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_following),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
||||||
|
)
|
||||||
|
OptionCard(
|
||||||
|
label = stringResource(R.string.event_delete_option_series),
|
||||||
|
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,3 +37,21 @@ fun reminderLeadTimeLabel(minutes: Int): String = when {
|
|||||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanise a plain duration — no "before": "45 minutes", "8 hours", or both
|
||||||
|
* parts for a mixed length ("1 hour 30 minutes"). Shared by the snooze delay and
|
||||||
|
* the default event duration (#54).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun durationLabel(minutes: Int): String {
|
||||||
|
val hours = minutes / 60
|
||||||
|
val rest = minutes % 60
|
||||||
|
val hoursLabel = pluralStringResource(R.plurals.duration_hours, hours, hours)
|
||||||
|
val minutesLabel = pluralStringResource(R.plurals.duration_minutes, rest, rest)
|
||||||
|
return when {
|
||||||
|
hours == 0 -> minutesLabel
|
||||||
|
rest == 0 -> hoursLabel
|
||||||
|
else -> stringResource(R.string.duration_hours_minutes, hoursLabel, minutesLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
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.calendar.NoSuchEventException
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
||||||
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
|
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||||
|
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
||||||
|
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val MILLIS_PER_MINUTE = 60_000
|
||||||
|
private const val MINUTES_PER_DAY = 24 * 60
|
||||||
|
|
||||||
|
/** Where a dragged event should land. */
|
||||||
|
sealed interface MoveTarget {
|
||||||
|
/** A new start instant — a timeline drag, which moves time and day at once. */
|
||||||
|
data class Start(val instant: Instant) : MoveTarget
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-day shift, keeping the time of day — a month-grid or all-day drag.
|
||||||
|
* A delta rather than a target date, so the screen and this view model can't
|
||||||
|
* disagree by a day over which zone the event's first day is resolved in.
|
||||||
|
*/
|
||||||
|
data class ByDays(val days: Int) : MoveTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
|
||||||
|
* own times (`Instances.BEGIN`/`END`), as the detail and edit screens pass them.
|
||||||
|
*/
|
||||||
|
data class MoveRequest(
|
||||||
|
val eventId: Long,
|
||||||
|
val beginMillis: Long,
|
||||||
|
val endMillis: Long,
|
||||||
|
val target: MoveTarget,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A recurring drop waiting for the user to pick how far it reaches.
|
||||||
|
* [occurrenceOnly] means a wider write would leave the rule and the series
|
||||||
|
* anchor disagreeing — either the rule names days one moved occurrence can't
|
||||||
|
* re-derive (`BYDAY=MO,WE`, `2TH`, …), or the shift wouldn't carry the anchor
|
||||||
|
* across the same midnights the occurrence crossed.
|
||||||
|
*/
|
||||||
|
data class MoveScopePrompt(val occurrenceOnly: Boolean)
|
||||||
|
|
||||||
|
/** The inverse of a completed move, for the undo action. */
|
||||||
|
data class MoveUndo(
|
||||||
|
val eventId: Long,
|
||||||
|
/** The form as it now stands (the moved state). */
|
||||||
|
val moved: EventForm,
|
||||||
|
/** The form as it stood before the move. */
|
||||||
|
val restored: EventForm,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface MoveOutcome {
|
||||||
|
/**
|
||||||
|
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
||||||
|
* confirmation message; [undo] is null when the write has no clean inverse.
|
||||||
|
*/
|
||||||
|
data class Moved(
|
||||||
|
val startMillis: Long,
|
||||||
|
val isAllDay: Boolean,
|
||||||
|
val undo: MoveUndo?,
|
||||||
|
) : MoveOutcome
|
||||||
|
|
||||||
|
data object Undone : MoveOutcome
|
||||||
|
|
||||||
|
/** `WRITE_CALENDAR` was revoked between the drop and the provider call. */
|
||||||
|
data object WriteDenied : MoveOutcome
|
||||||
|
|
||||||
|
/** The event vanished (sync, another device) between the read and the write. */
|
||||||
|
data object Gone : MoveOutcome
|
||||||
|
|
||||||
|
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
||||||
|
data object BlockedSeriesEnd : MoveOutcome
|
||||||
|
|
||||||
|
data object Failed : MoveOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a drag-and-drop reschedule (#68) through the same repository calls the
|
||||||
|
* edit screen's save uses, so recurring writes, reminder reconciliation and
|
||||||
|
* attendee preservation behave identically. Hosted at `CalendarHost` so one
|
||||||
|
* instance serves every calendar view and survives view switches. The full
|
||||||
|
* prefilled form is carried through the write — a stripped one would wipe the
|
||||||
|
* reminders and attendees the occurrence-exception path reconciles.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class RescheduleViewModel @Inject constructor(
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
|
||||||
|
val scopePrompt: StateFlow<MoveScopePrompt?> = _scopePrompt.asStateFlow()
|
||||||
|
|
||||||
|
private val _outcome = MutableStateFlow<MoveOutcome?>(null)
|
||||||
|
val outcome: StateFlow<MoveOutcome?> = _outcome.asStateFlow()
|
||||||
|
|
||||||
|
private var pending: PreparedMove? = null
|
||||||
|
|
||||||
|
private val _inFlight = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True from the moment a drop is accepted until its write settles — the
|
||||||
|
* window the dropped block holds its landing position for.
|
||||||
|
*/
|
||||||
|
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||||
|
|
||||||
|
private val _undoStarted = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticks the moment an undo write begins, before the provider has anything to
|
||||||
|
* re-read, so the view that drew the drop can carry its chip back rather than
|
||||||
|
* let it reappear on the old day.
|
||||||
|
*/
|
||||||
|
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set from the moment a drop is accepted until its write settles. Two drops
|
||||||
|
* of the same recurring event inside that window would compute their shift
|
||||||
|
* from the same pre-move occurrence, and the shifts would compound.
|
||||||
|
*/
|
||||||
|
private var busy = false
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
_inFlight.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The calendars whose events may be dragged. Nothing below the UI guards
|
||||||
|
* this, so a block outside this set registers no drag gesture at all.
|
||||||
|
*/
|
||||||
|
val movableCalendarIds: StateFlow<Set<Long>> = repository.calendars()
|
||||||
|
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
|
||||||
|
.catch { emit(emptySet<Long>()) }
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Everything a drop needs, resolved before anything is written. */
|
||||||
|
private data class PreparedMove(
|
||||||
|
val request: MoveRequest,
|
||||||
|
val original: EventForm,
|
||||||
|
val updated: EventForm,
|
||||||
|
/** True for a series master with a rule — an exception row is not one. */
|
||||||
|
val isRecurring: Boolean,
|
||||||
|
/** False when the rule names days this move can't re-derive. */
|
||||||
|
val canRealign: Boolean,
|
||||||
|
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
||||||
|
val newAnchorDate: LocalDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a drop, unless one is already being written. False means nothing will
|
||||||
|
* be written and the caller must let its held copy go now — waiting on
|
||||||
|
* [inFlight] would strand it until the settle timeout instead.
|
||||||
|
*/
|
||||||
|
fun move(request: MoveRequest): Boolean {
|
||||||
|
if (busy || _scopePrompt.value != null) return false
|
||||||
|
busy = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
val prepared = prepare(request)
|
||||||
|
if (prepared == null) {
|
||||||
|
busy = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (!prepared.isRecurring) {
|
||||||
|
write(prepared, RecurringWriteScope.AllEvents)
|
||||||
|
busy = false
|
||||||
|
} else {
|
||||||
|
// Still busy: the scope dialog is now the thing in flight.
|
||||||
|
pending = prepared
|
||||||
|
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer the scope dialog. */
|
||||||
|
fun moveWithScope(scope: RecurringWriteScope) {
|
||||||
|
val prepared = pending ?: return
|
||||||
|
// Guard against the dialog ever offering a scope the rule can't carry.
|
||||||
|
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
|
||||||
|
pending = null
|
||||||
|
_scopePrompt.value = null
|
||||||
|
viewModelScope.launch {
|
||||||
|
write(prepared, scope)
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dismiss the scope dialog without writing. */
|
||||||
|
fun cancelScope() {
|
||||||
|
pending = null
|
||||||
|
busy = false
|
||||||
|
_scopePrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a completed move back where it came from. The outcome stands until the
|
||||||
|
* inverse write reports back, so the one chip changes what it says rather
|
||||||
|
* than closing and reopening. False when another write is already running —
|
||||||
|
* the chip hides its action for that window, so this is the backstop.
|
||||||
|
*/
|
||||||
|
fun undo(undo: MoveUndo): Boolean {
|
||||||
|
if (busy) return false
|
||||||
|
busy = true
|
||||||
|
_undoStarted.value += 1
|
||||||
|
viewModelScope.launch {
|
||||||
|
_outcome.value = try {
|
||||||
|
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
|
||||||
|
MoveOutcome.Undone
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
MoveOutcome.WriteDenied
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
MoveOutcome.Gone
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MoveOutcome.Failed
|
||||||
|
}
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the outcome once the screen has shown it. */
|
||||||
|
fun consumeOutcome() {
|
||||||
|
_outcome.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun prepare(request: MoveRequest): PreparedMove? {
|
||||||
|
val detail = try {
|
||||||
|
repository.eventDetail(request.eventId)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
_outcome.value = MoveOutcome.Gone
|
||||||
|
return null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_outcome.value = MoveOutcome.Failed
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||||
|
val shifted = when (val target = request.target) {
|
||||||
|
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||||
|
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
||||||
|
}
|
||||||
|
// A zero-distance drop is not a write.
|
||||||
|
if (shifted == original) return null
|
||||||
|
|
||||||
|
// The UNTIL check is deferred to the write, which knows how far the move
|
||||||
|
// reaches and so which date has to clear it.
|
||||||
|
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||||
|
_outcome.value = MoveOutcome.Failed
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exception row stands alone whatever rule a sync adapter left on it.
|
||||||
|
val isRecurring = original.rrule != null && !detail.isException
|
||||||
|
val movedDay = shifted.start.date != original.start.date
|
||||||
|
// The series anchor moves by the same *wall-clock* shift as the dragged
|
||||||
|
// occurrence, so only a whole-day shift carries it across the same number
|
||||||
|
// of midnights whatever time of day it sits at. A same-date drag has its
|
||||||
|
// own, narrower check — see anchorKeepsItsDay.
|
||||||
|
val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time
|
||||||
|
val realigned = if (isRecurring && movedDay) {
|
||||||
|
if (wholeDayShift) {
|
||||||
|
realignRecurrence(
|
||||||
|
requireNotNull(original.rrule),
|
||||||
|
original.start.date,
|
||||||
|
shifted.start.date,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
original.rrule
|
||||||
|
}
|
||||||
|
return PreparedMove(
|
||||||
|
request = request,
|
||||||
|
original = original,
|
||||||
|
// A rule we can't re-derive stays verbatim: the only scope offered
|
||||||
|
// then is the single occurrence, whose exception row carries no rule.
|
||||||
|
updated = shifted.copy(rrule = realigned ?: original.rrule),
|
||||||
|
isRecurring = isRecurring,
|
||||||
|
canRealign = when {
|
||||||
|
!isRecurring -> true
|
||||||
|
movedDay -> realigned != null
|
||||||
|
else -> anchorKeepsItsDay(detail, original, shifted, zone)
|
||||||
|
},
|
||||||
|
// Where the series row's own DTSTART lands, given the anchor moves by
|
||||||
|
// the same shift. Only read under canRealign, which is what
|
||||||
|
// guarantees the anchor really does travel this many days.
|
||||||
|
newAnchorDate = anchorDate(detail, original, zone)
|
||||||
|
.plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
|
||||||
|
val request = prepared.request
|
||||||
|
if (endsBeforeItStarts(prepared, scope)) {
|
||||||
|
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_outcome.value = try {
|
||||||
|
when {
|
||||||
|
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
|
||||||
|
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
|
||||||
|
|
||||||
|
scope == RecurringWriteScope.ThisEvent ->
|
||||||
|
repository.updateOccurrence(
|
||||||
|
request.eventId,
|
||||||
|
request.beginMillis,
|
||||||
|
prepared.updated,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> repository.updateEventFromOccurrence(
|
||||||
|
eventId = request.eventId,
|
||||||
|
beginMillis = request.beginMillis,
|
||||||
|
original = prepared.original,
|
||||||
|
updated = prepared.updated,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MoveOutcome.Moved(
|
||||||
|
startMillis = movedStartMillis(prepared),
|
||||||
|
isAllDay = prepared.updated.isAllDay,
|
||||||
|
undo = undoFor(prepared, scope),
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
MoveOutcome.WriteDenied
|
||||||
|
} catch (e: NoSuchEventException) {
|
||||||
|
MoveOutcome.Gone
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MoveOutcome.Failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this write would leave a rule whose `UNTIL` precedes the first day
|
||||||
|
* it now applies to — the provider then generates nothing at all. Which date
|
||||||
|
* has to clear `UNTIL` depends on the scope: a whole-series move carries the
|
||||||
|
* series *anchor*, a split starts at the moved occurrence, and a single
|
||||||
|
* occurrence becomes an exception row that no `UNTIL` constrains.
|
||||||
|
*/
|
||||||
|
private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
|
||||||
|
if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false
|
||||||
|
val rule = prepared.updated.rrule ?: return false
|
||||||
|
val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false
|
||||||
|
val firstDay = if (scope == RecurringWriteScope.AllEvents) {
|
||||||
|
prepared.newAnchorDate
|
||||||
|
} else {
|
||||||
|
prepared.updated.start.date
|
||||||
|
}
|
||||||
|
return end.date < firstDay
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a same-date drag leaves the series anchor on its own day too. The
|
||||||
|
* anchor moves by the same wall-clock delta as the occurrence, and normally
|
||||||
|
* shares its time of day — but a row with no `EVENT_TIMEZONE` resolves the
|
||||||
|
* two in zones that can sit a DST hour apart, so a near-midnight drag could
|
||||||
|
* carry the anchor across a midnight the occurrence never crossed and leave
|
||||||
|
* `BYDAY` naming the wrong weekday.
|
||||||
|
*/
|
||||||
|
private fun anchorKeepsItsDay(
|
||||||
|
detail: EventDetail,
|
||||||
|
original: EventForm,
|
||||||
|
shifted: EventForm,
|
||||||
|
zone: TimeZone,
|
||||||
|
): Boolean {
|
||||||
|
val anchorZone = if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone)
|
||||||
|
val anchorMinute = detail.instance.start.toLocalDateTime(anchorZone)
|
||||||
|
.time.toMillisecondOfDay() / MILLIS_PER_MINUTE
|
||||||
|
val delta = (
|
||||||
|
shifted.start.time.toMillisecondOfDay() - original.start.time.toMillisecondOfDay()
|
||||||
|
) / MILLIS_PER_MINUTE
|
||||||
|
return anchorMinute + delta in 0 until MINUTES_PER_DAY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The series row's own start date — for a recurring master, `EventDetail`
|
||||||
|
* carries the row's DTSTART rather than the tapped occurrence's. Read in the
|
||||||
|
* same anchoring the write path uses: UTC for an all-day series.
|
||||||
|
*/
|
||||||
|
private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate =
|
||||||
|
detail.instance.start
|
||||||
|
.toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone))
|
||||||
|
.date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo is offered only where the inverse is one symmetric write: a
|
||||||
|
* non-recurring event, and a whole-series move. "This event" leaves an
|
||||||
|
* exception row behind and "this and following" splits the series with an
|
||||||
|
* UNTIL truncation — neither is undone by shifting back.
|
||||||
|
*/
|
||||||
|
private fun undoFor(prepared: PreparedMove, scope: RecurringWriteScope): MoveUndo? =
|
||||||
|
if (!prepared.isRecurring || scope == RecurringWriteScope.AllEvents) {
|
||||||
|
MoveUndo(
|
||||||
|
eventId = prepared.request.eventId,
|
||||||
|
moved = prepared.updated,
|
||||||
|
restored = prepared.original,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the dragged occurrence now begins, in the same anchoring the views
|
||||||
|
* read dates back in: UTC midnight for an all-day event.
|
||||||
|
*/
|
||||||
|
private fun movedStartMillis(prepared: PreparedMove): Long {
|
||||||
|
val form = prepared.updated
|
||||||
|
return if (form.isAllDay) {
|
||||||
|
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||||
|
} else {
|
||||||
|
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
||||||
|
.toEpochMilliseconds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
|||||||
private const val PATTERN_24 = "HH:mm"
|
private const val PATTERN_24 = "HH:mm"
|
||||||
private const val PATTERN_12 = "h:mm a"
|
private const val PATTERN_12 = "h:mm a"
|
||||||
private const val HOUR_PATTERN_12 = "h a"
|
private const val HOUR_PATTERN_12 = "h a"
|
||||||
|
private const val HOUR_MINUTE_PATTERN_12 = "h:mm"
|
||||||
|
|
||||||
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||||
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||||
@@ -42,6 +43,18 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
|
|||||||
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time shown in the timeline gutter while a block is dragged: 24h →
|
||||||
|
* "09:15", 12h → "9:15". The meridiem is dropped — the hour labels around it
|
||||||
|
* already carry it, and the gutter is too narrow.
|
||||||
|
*/
|
||||||
|
fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String {
|
||||||
|
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||||
|
val pattern = if (is24Hour) PATTERN_24 else HOUR_MINUTE_PATTERN_12
|
||||||
|
return LocalTime.of(clamped / 60, clamped % 60)
|
||||||
|
.format(DateTimeFormatter.ofPattern(pattern, locale))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||||
* the prior look); 12h → "1 PM".
|
* the prior look); 12h → "1 PM".
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.MutatePriority
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
||||||
|
const val DRAG_SNAP_MINUTES: Int = 15
|
||||||
|
|
||||||
|
/** How close to a timeline edge the finger must get before the view scrolls. */
|
||||||
|
private val AUTO_SCROLL_EDGE = 64.dp
|
||||||
|
|
||||||
|
/** Fastest auto-scroll step, per frame, right at the edge. */
|
||||||
|
private val AUTO_SCROLL_STEP = 16.dp
|
||||||
|
|
||||||
|
/** A block being dragged, in root coordinates so it can be drawn in an overlay. */
|
||||||
|
data class TimelineDrag(
|
||||||
|
val event: EventInstance,
|
||||||
|
val date: LocalDate,
|
||||||
|
val startMin: Int,
|
||||||
|
val endMin: Int,
|
||||||
|
val topLeftInRoot: Offset,
|
||||||
|
val sizePx: IntSize,
|
||||||
|
) {
|
||||||
|
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
||||||
|
val slot: Pair<LocalDate, Int> get() = date to startMin
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a finished drag asks its event to go. */
|
||||||
|
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timeline's live geometry, republished on every layout. Plain fields rather
|
||||||
|
* than snapshot state: it changes on every scroll frame, and the drag loop reads
|
||||||
|
* it per frame anyway.
|
||||||
|
*/
|
||||||
|
class TimelineGeometry {
|
||||||
|
/** The day-columns row — scrolling *content*, so its root position folds in the scroll. */
|
||||||
|
var grid: LayoutCoordinates? = null
|
||||||
|
|
||||||
|
/** The scroll viewport, for the edge that triggers auto-scrolling. */
|
||||||
|
var viewport: LayoutCoordinates? = null
|
||||||
|
var scroll: ScrollState? = null
|
||||||
|
var hourPx: Float = 0f
|
||||||
|
|
||||||
|
/** Column pitch — one column plus the gap after it. */
|
||||||
|
var columnWidthPx: Float = 0f
|
||||||
|
var columnGapPx: Float = 0f
|
||||||
|
var edgePx: Float = 0f
|
||||||
|
var stepPx: Float = 0f
|
||||||
|
var days: List<LocalDate> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
var isRtl: Boolean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoisted drag state for one timeline (#68). It lives above the per-page
|
||||||
|
* `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.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class TimelineDragController {
|
||||||
|
val geometry = TimelineGeometry()
|
||||||
|
|
||||||
|
var drag: TimelineDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dropped block, held at the slot it landed on while the write runs — the
|
||||||
|
* grid behind it still shows the old time until the query re-reads.
|
||||||
|
*/
|
||||||
|
var settling: TimelineDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which block is lifted, and whether anything is. Separate snapshot state
|
||||||
|
* from [drag], which changes every frame, so blocks that only need "am I the
|
||||||
|
* ghost" don't recompose that often. Stays set through [settling].
|
||||||
|
*/
|
||||||
|
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the settled drop came from, and which event row it belongs to. The
|
||||||
|
* instance id alone can't identify the source block for the length of the
|
||||||
|
* write: the provider regenerates `Instances` rows, so a re-read carrying the
|
||||||
|
* *old* time can arrive under a new instance id.
|
||||||
|
*/
|
||||||
|
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
|
||||||
|
|
||||||
|
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||||
|
var isDragging: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the grid itself now draws the settled drop at its landing slot.
|
||||||
|
* The copy may only be handed back once this is true, or the source ghost
|
||||||
|
* flashes back to full opacity in its old slot.
|
||||||
|
*/
|
||||||
|
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var source: TimedBlock? = null
|
||||||
|
private var grab = Offset.Zero
|
||||||
|
private var pointer = Offset.Zero
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
private var originSlot: Pair<LocalDate, Int>? = null
|
||||||
|
|
||||||
|
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
||||||
|
source = block
|
||||||
|
settling = null
|
||||||
|
isDragging = true
|
||||||
|
liftedInstanceId = block.event.instanceId
|
||||||
|
grab = pointerInRoot - blockInRoot
|
||||||
|
pointer = pointerInRoot
|
||||||
|
originSlot = null
|
||||||
|
recompute()
|
||||||
|
originSlot = drag?.slot
|
||||||
|
}
|
||||||
|
|
||||||
|
fun move(pointerInRoot: Offset) {
|
||||||
|
pointer = pointerInRoot
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
source = null
|
||||||
|
isDragging = false
|
||||||
|
liftedInstanceId = null
|
||||||
|
originSlot = null
|
||||||
|
drag = null
|
||||||
|
settling = null
|
||||||
|
settledOnGrid = false
|
||||||
|
settledOrigin = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [block] is the one whose copy is in flight, and so must stay a
|
||||||
|
* ghost: the instance the finger picked up, or once dropped whatever now
|
||||||
|
* sits in the slot it left.
|
||||||
|
*/
|
||||||
|
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
|
||||||
|
if (liftedInstanceId == null) return false
|
||||||
|
if (block.event.instanceId == liftedInstanceId) return true
|
||||||
|
val (eventId, originDate, originMin) = settledOrigin ?: return false
|
||||||
|
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
|
||||||
|
* 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`.
|
||||||
|
*/
|
||||||
|
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||||
|
val landed = settling ?: return
|
||||||
|
if (settledOnGrid || landed.date != date) return
|
||||||
|
settledOnGrid = blocks.any { block ->
|
||||||
|
block.startMin == landed.startMin &&
|
||||||
|
(
|
||||||
|
block.event.eventId == landed.event.eventId ||
|
||||||
|
block.event.title == landed.event.title
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the drag, handing back where it landed — null when it never resolved,
|
||||||
|
* or when it landed back on the slot it started from. A real drop keeps its
|
||||||
|
* copy on the target as [settling] until [release].
|
||||||
|
*/
|
||||||
|
fun finish(): TimelineDrop? {
|
||||||
|
val landed = drag
|
||||||
|
val origin = originSlot
|
||||||
|
cancel()
|
||||||
|
if (landed == null || landed.slot == origin) return null
|
||||||
|
settling = landed
|
||||||
|
liftedInstanceId = landed.event.instanceId
|
||||||
|
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
|
||||||
|
return TimelineDrop(landed.event, landed.date, landed.startMin)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop ghosting the source — the grid draws the drop itself by now — while
|
||||||
|
* the copy is still dissolving. Un-ghosting at [release] instead would leave
|
||||||
|
* the block dim under the fading copy and then brighten it: a visible dip.
|
||||||
|
*/
|
||||||
|
fun handOver() {
|
||||||
|
liftedInstanceId = null
|
||||||
|
settledOrigin = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the copy, once it has faded into the grid's own block. */
|
||||||
|
fun release() {
|
||||||
|
settling = null
|
||||||
|
settledOnGrid = false
|
||||||
|
handOver()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-derive the target from the pointer's *root* position. Recomputed rather
|
||||||
|
* than accumulated from `positionChange()`, because a stationary finger emits
|
||||||
|
* no move events while auto-scroll walks the content underneath it.
|
||||||
|
*/
|
||||||
|
fun recompute() {
|
||||||
|
val block = source ?: return
|
||||||
|
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||||
|
val hourPx = geometry.hourPx
|
||||||
|
val columnPx = geometry.columnWidthPx
|
||||||
|
val days = geometry.days
|
||||||
|
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return
|
||||||
|
|
||||||
|
val origin = grid.positionInRoot()
|
||||||
|
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
||||||
|
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||||
|
// Clamp the start into the target day; a tail past midnight is fine.
|
||||||
|
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||||
|
// The event's own length, not the block's: TimedBlock.endMin is clipped
|
||||||
|
// at midnight, which would draw a 22:00–02:00 event as a two-hour copy.
|
||||||
|
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 dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||||
|
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||||
|
drag = TimelineDrag(
|
||||||
|
event = block.event,
|
||||||
|
date = days[dayIndex],
|
||||||
|
startMin = startMin,
|
||||||
|
endMin = startMin + span,
|
||||||
|
topLeftInRoot = Offset(
|
||||||
|
x = origin.x + column * columnPx,
|
||||||
|
y = origin.y + startMin / 60f * hourPx,
|
||||||
|
),
|
||||||
|
sizePx = IntSize(
|
||||||
|
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||||
|
height.roundToInt(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold the scroll for the whole drag and nudge it once per frame while the
|
||||||
|
* finger sits near an edge. One `scroll` call, since a loop of
|
||||||
|
* `animateScrollBy` would re-acquire the mutex and cancel the last.
|
||||||
|
*/
|
||||||
|
suspend fun autoScroll() {
|
||||||
|
val scroll = geometry.scroll ?: return
|
||||||
|
scroll.scroll(MutatePriority.UserInput) {
|
||||||
|
while (true) {
|
||||||
|
withFrameNanos { }
|
||||||
|
recompute()
|
||||||
|
val step = edgeStep()
|
||||||
|
if (step != 0f) scrollBy(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun edgeStep(): Float {
|
||||||
|
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
|
||||||
|
val edge = geometry.edgePx
|
||||||
|
if (edge <= 0f) return 0f
|
||||||
|
val bounds = viewport.boundsInRoot()
|
||||||
|
val fromTop = pointer.y - bounds.top
|
||||||
|
val fromBottom = bounds.bottom - pointer.y
|
||||||
|
return when {
|
||||||
|
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
|
||||||
|
fromBottom < edge -> (edge - fromBottom) / edge * geometry.stepPx
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberTimelineDragController(): TimelineDragController {
|
||||||
|
val controller = remember { TimelineDragController() }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||||
|
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [block] actually begins on [day] rather than being the tail of an
|
||||||
|
* event that started earlier. A clipped block's top edge is midnight, not the
|
||||||
|
* event's start, so dragging it would move the event to a time it never had.
|
||||||
|
*/
|
||||||
|
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
|
||||||
|
event.start.toLocalDateTime(zone).date == day
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant a drop asks for, 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 =
|
||||||
|
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A beat after the grid draws the drop, so its own block is under way before the
|
||||||
|
* copy starts dissolving.
|
||||||
|
*/
|
||||||
|
const val SETTLE_GRACE_MILLIS: Long = 60L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait for a grid that never confirms the drop — it landed on a day
|
||||||
|
* this timeline doesn't show, or the write failed. The copy has to go either way.
|
||||||
|
*/
|
||||||
|
private const val SETTLE_TIMEOUT_MILLIS = 900L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hand-over: the copy dissolves over this while the grid's own block slides
|
||||||
|
* in under it. Kept short, because a full-width copy sits over any neighbour it
|
||||||
|
* now shares a lane with until it is gone.
|
||||||
|
*/
|
||||||
|
const val SETTLE_FADE_MILLIS: Int = 250
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The floating block, drawn over the whole calendar so it is free of the day
|
||||||
|
* column's clip and of the scroll viewport's rounded corners.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||||
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val soften = LocalSoftenColors.current
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val moveInFlight = moveInFlight()
|
||||||
|
|
||||||
|
// Both live here rather than beside the controller: they read [drag], which
|
||||||
|
// changes every frame, and this composable is the one that is meant to.
|
||||||
|
DragSnapHaptics(controller.drag?.slot)
|
||||||
|
LaunchedEffect(controller.isDragging) {
|
||||||
|
if (controller.isDragging) controller.autoScroll()
|
||||||
|
}
|
||||||
|
// Hold the landed copy until the write is done — including the time a
|
||||||
|
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||||
|
// drop itself.
|
||||||
|
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
|
||||||
|
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||||
|
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||||
|
handingOver = true
|
||||||
|
controller.handOver()
|
||||||
|
delay(SETTLE_FADE_MILLIS.toLong())
|
||||||
|
controller.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// A copy of a block still in the tree behind it; announcing it again
|
||||||
|
// would duplicate the event for the drag's duration.
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||||
|
) {
|
||||||
|
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||||
|
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||||
|
val landed = controller.drag == null
|
||||||
|
val lift by animateFloatAsState(
|
||||||
|
targetValue = if (landed || reduceMotion) 0f else 1f,
|
||||||
|
label = "drag-lift",
|
||||||
|
)
|
||||||
|
val copyAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (handingOver) 0f else 1f,
|
||||||
|
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||||
|
label = "drag-handover",
|
||||||
|
)
|
||||||
|
val fill = eventFill(drag.event.color, dark, soften)
|
||||||
|
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
// An end past midnight wraps rather than saturating, so a four-hour
|
||||||
|
// event dragged to 22:00 reads "22:00–02:00" and not "22:00–24:00".
|
||||||
|
val endMin = if (drag.endMin > MINUTES_PER_DAY) {
|
||||||
|
drag.endMin % MINUTES_PER_DAY
|
||||||
|
} else {
|
||||||
|
drag.endMin
|
||||||
|
}
|
||||||
|
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
||||||
|
formatMinuteOfDay(endMin, use24Hour, locale)
|
||||||
|
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(
|
||||||
|
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
||||||
|
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.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),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How tall one hour is drawn in the week and day timelines (#56).
|
||||||
|
*
|
||||||
|
* One shared setting for both views: they are the same grid at different widths,
|
||||||
|
* and a scale that only applied to one of them would read as a bug.
|
||||||
|
*
|
||||||
|
* [FitDay] is the answer to the actual complaint behind the issue — on a tall
|
||||||
|
* phone the default scale shows about half a day, so a whole week can hide
|
||||||
|
* appointments below the fold. It derives the hour height from the timeline's
|
||||||
|
* own viewport instead of a fixed value.
|
||||||
|
*
|
||||||
|
* [Custom] is what a pinch on the timeline leaves behind. The presets are the
|
||||||
|
* settings-screen vocabulary; a pinch is a direct manipulation and should be
|
||||||
|
* able to land anywhere between them, so it gets its own case rather than
|
||||||
|
* snapping to the nearest named step.
|
||||||
|
*/
|
||||||
|
sealed interface TimelineScale {
|
||||||
|
|
||||||
|
/** Whole day in one screen: the hour height follows the viewport. */
|
||||||
|
data object FitDay : TimelineScale
|
||||||
|
|
||||||
|
/** Denser than the default, still a fixed height. */
|
||||||
|
data object Compact : TimelineScale
|
||||||
|
|
||||||
|
/** The historical 56dp scale. */
|
||||||
|
data object Regular : TimelineScale
|
||||||
|
|
||||||
|
/** Roomier blocks, more scrolling. */
|
||||||
|
data object Comfortable : TimelineScale
|
||||||
|
|
||||||
|
/** A height the user pinched to. Build it through [custom], which clamps. */
|
||||||
|
data class Custom(val hourHeight: Dp) : TimelineScale
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The named steps the settings picker offers, coarse to roomy. */
|
||||||
|
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pinched hour height. The floor a pinch actually stops at depends on
|
||||||
|
* the viewport and is applied when the height is resolved (see
|
||||||
|
* [hourHeight]); this only holds a stored value to something sane.
|
||||||
|
*/
|
||||||
|
fun custom(hourHeight: Dp): Custom =
|
||||||
|
Custom(hourHeight.coerceIn(MIN_STORED_HOUR_HEIGHT, MAX_PINCH_HOUR_HEIGHT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hour height for this scale. [viewportHeight] is the visible height of the
|
||||||
|
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
|
||||||
|
*
|
||||||
|
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
|
||||||
|
* being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
|
||||||
|
* blocks absurdly. On a screen too short for the whole day the clamp wins and
|
||||||
|
* the timeline still scrolls a little — honest, rather than unreadable.
|
||||||
|
*
|
||||||
|
* A pinched height is held to [fillHourHeight] here and not only in the gesture,
|
||||||
|
* because the viewport can change under a height that was already stored: pinch
|
||||||
|
* all the way out in landscape and the same value would leave portrait with dead
|
||||||
|
* space under midnight.
|
||||||
|
*/
|
||||||
|
fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
||||||
|
TimelineScale.FitDay -> (viewportHeight / 24f).coerceIn(FIT_DAY_MIN, FIT_DAY_MAX)
|
||||||
|
TimelineScale.Compact -> 40.dp
|
||||||
|
TimelineScale.Regular -> 56.dp
|
||||||
|
TimelineScale.Comfortable -> 80.dp
|
||||||
|
is TimelineScale.Custom -> this.hourHeight
|
||||||
|
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
||||||
|
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minute of the day a tap [offsetY] px down a timeline column means, snapped
|
||||||
|
* to the hour it landed in.
|
||||||
|
*
|
||||||
|
* [hourPx] must be the hour height the column is drawing at *now* — a tap
|
||||||
|
* detector that captured it when it was installed maps taps to a pre-pinch grid
|
||||||
|
* (#148). A zero or negative height (a viewport measured at nothing) has no grid
|
||||||
|
* to read, so it answers midnight rather than dividing by it.
|
||||||
|
*/
|
||||||
|
fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
|
||||||
|
if (hourPx <= 0f) 0 else (offsetY / hourPx).toInt().coerceIn(0, 23) * 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
|
||||||
|
* floor so a 15-minute event stays tappable, but the floor scales with the hour
|
||||||
|
* height — a fixed 24dp would swallow half an hour once zoomed out and make
|
||||||
|
* short events overlap their neighbours.
|
||||||
|
*/
|
||||||
|
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. */
|
||||||
|
val FIT_DAY_MIN = 24.dp
|
||||||
|
|
||||||
|
/** Largest hour height [TimelineScale.FitDay] will resolve to. */
|
||||||
|
val FIT_DAY_MAX = 96.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hour height at which all 24 hours exactly fill [viewportHeight] — how far
|
||||||
|
* out a pinch can zoom.
|
||||||
|
*
|
||||||
|
* Below it there is no more day left to uncover, so the only thing shrinking
|
||||||
|
* further buys is empty space under midnight. Zooming out means "show me more of
|
||||||
|
* the day", and once the whole day is on screen that request is answered.
|
||||||
|
*/
|
||||||
|
fun fillHourHeight(viewportHeight: Dp): Dp = viewportHeight / 24f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ceiling for a pinch, deliberately far above [TimelineScale.Comfortable]: a
|
||||||
|
* pinch is a deliberate act, so someone zooming in on a single busy afternoon
|
||||||
|
* should be allowed to go further than any preset offers.
|
||||||
|
*/
|
||||||
|
val MAX_PINCH_HOUR_HEIGHT = 240.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor for a *stored* pinched height, which only has to stay sane enough to be
|
||||||
|
* re-clamped against whatever viewport it is later shown in. The floor a pinch
|
||||||
|
* stops at is [fillHourHeight].
|
||||||
|
*/
|
||||||
|
private val MIN_STORED_HOUR_HEIGHT = 1.dp
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val TimelineScale.labelRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
TimelineScale.FitDay -> R.string.timeline_scale_fit_day
|
||||||
|
TimelineScale.Compact -> R.string.timeline_scale_compact
|
||||||
|
TimelineScale.Regular -> R.string.timeline_scale_regular
|
||||||
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
||||||
|
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val TimelineScale.descriptionRes: Int
|
||||||
|
get() = when (this) {
|
||||||
|
TimelineScale.FitDay -> R.string.timeline_scale_fit_day_summary
|
||||||
|
TimelineScale.Compact -> R.string.timeline_scale_compact_summary
|
||||||
|
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
||||||
|
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
|
||||||
|
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks a stored custom height; the rest of the value is its dp. */
|
||||||
|
private const val CUSTOM_PREFIX = "custom:"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* the enum names this used to be, so a value written before the pinch existed
|
||||||
|
* still reads back as the same preset.
|
||||||
|
*/
|
||||||
|
private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
|
||||||
|
TimelineScale.FitDay to "FitDay",
|
||||||
|
TimelineScale.Compact to "Compact",
|
||||||
|
TimelineScale.Regular to "Regular",
|
||||||
|
TimelineScale.Comfortable to "Comfortable",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialise for the `timeline_scale` preference; see [parseTimelineScale].
|
||||||
|
*
|
||||||
|
* Spelled out rather than an `else` into [PRESET_NAMES], so a preset added later
|
||||||
|
* without a stored name is a compile error here instead of a crash the first
|
||||||
|
* time someone picks it.
|
||||||
|
*/
|
||||||
|
fun TimelineScale.storageValue(): String = when (this) {
|
||||||
|
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
||||||
|
TimelineScale.FitDay,
|
||||||
|
TimelineScale.Compact,
|
||||||
|
TimelineScale.Regular,
|
||||||
|
TimelineScale.Comfortable,
|
||||||
|
-> PRESET_NAMES.getValue(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a stored scale. Anything unrecognised — a null key, a name from a
|
||||||
|
* future release, a truncated custom height — falls back to
|
||||||
|
* [TimelineScale.Regular] rather than throwing, like the other enum prefs.
|
||||||
|
*/
|
||||||
|
fun parseTimelineScale(stored: String?): TimelineScale = when {
|
||||||
|
stored == null -> TimelineScale.Regular
|
||||||
|
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
||||||
|
?.let { TimelineScale.custom(it.dp) }
|
||||||
|
?: TimelineScale.Regular
|
||||||
|
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.calculateCentroid
|
||||||
|
import androidx.compose.foundation.gestures.calculateZoom
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scale the timelines draw at right now: the stored preference, or whatever
|
||||||
|
* a pinch is currently doing to it (#56).
|
||||||
|
*
|
||||||
|
* A pinch changes the scale on every pointer frame, which the preference alone
|
||||||
|
* cannot carry — a DataStore round trip per frame would lag the gesture badly
|
||||||
|
* behind the fingers. So the live value lives here in composition state and only
|
||||||
|
* the settled result is written back, once, when the fingers lift.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class TimelineZoom(
|
||||||
|
initial: TimelineScale,
|
||||||
|
private val persist: (TimelineScale) -> Unit,
|
||||||
|
) {
|
||||||
|
/** What the week and day timelines should draw at. */
|
||||||
|
var scale: TimelineScale by mutableStateOf(initial)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether fingers are rescaling the timeline right now. Snapshot state, so
|
||||||
|
* anything that tweens off [scale] can stand down for the gesture: the
|
||||||
|
* height changes every pointer frame, and a spring would spend the whole
|
||||||
|
* pinch chasing a target that has already moved.
|
||||||
|
*/
|
||||||
|
var isPinching: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* snap the timeline back while the user is still pinching.
|
||||||
|
*/
|
||||||
|
fun adopt(stored: TimelineScale) {
|
||||||
|
if (!isPinching) scale = stored
|
||||||
|
}
|
||||||
|
|
||||||
|
fun beginPinch() {
|
||||||
|
isPinching = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pinchTo(hourHeight: Dp) {
|
||||||
|
scale = TimelineScale.custom(hourHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endPinch() {
|
||||||
|
isPinching = false
|
||||||
|
persist(scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zoom the timelines read. Falls back to a detached instance — one shared
|
||||||
|
* lazy value, not one per read, or each read would hand out a fresh state — so a
|
||||||
|
* preview or test can render a timeline without the activity's provider.
|
||||||
|
*/
|
||||||
|
private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {} }
|
||||||
|
|
||||||
|
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
|
||||||
|
* back through [onPersist].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberTimelineZoom(
|
||||||
|
stored: TimelineScale,
|
||||||
|
onPersist: (TimelineScale) -> Unit,
|
||||||
|
): TimelineZoom {
|
||||||
|
val persist by rememberUpdatedState(onPersist)
|
||||||
|
val zoom = remember { TimelineZoom(stored) { persist(it) } }
|
||||||
|
// Picking a preset in Settings has to reach the timelines, and so does the
|
||||||
|
// stored value arriving after the first frame.
|
||||||
|
LaunchedEffect(stored) { zoom.adopt(stored) }
|
||||||
|
return zoom
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-finger pinch that rescales a 24-hour timeline (#56) — the gesture the
|
||||||
|
* issue actually asked for; the Settings presets stay as the accessible route to
|
||||||
|
* the same thing.
|
||||||
|
*
|
||||||
|
* Three gestures share this area, so the pinch is deliberately the fussiest
|
||||||
|
* about claiming it: nothing happens until a *second* finger is down and the
|
||||||
|
* spread has grown past [PINCH_SLOP], which leaves one-finger vertical scrolling
|
||||||
|
* and the horizontal week swipe untouched, and lets a two-finger drag still
|
||||||
|
* scroll. It watches on [PointerEventPass.Initial] because the scroll it has to
|
||||||
|
* outrank is a descendant — on the main pass the scroll would have consumed the
|
||||||
|
* drag before this ever saw it.
|
||||||
|
*
|
||||||
|
* [hourHeight] is the *resolved* height, so a pinch that starts from
|
||||||
|
* `FitDay` picks up where the viewport left it rather than jumping.
|
||||||
|
* [viewportHeight] sets how far out it can go — see [fillHourHeight].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberTimelinePinchZoom(
|
||||||
|
scrollState: ScrollState,
|
||||||
|
viewportHeight: Dp,
|
||||||
|
hourHeight: Dp,
|
||||||
|
zoom: TimelineZoom,
|
||||||
|
): Modifier {
|
||||||
|
// The gesture loop outlives any single composition, so it reads these
|
||||||
|
// through state handles rather than capturing what they were when it started.
|
||||||
|
val currentHourHeight = rememberUpdatedState(hourHeight)
|
||||||
|
val currentViewport = rememberUpdatedState(viewportHeight)
|
||||||
|
return Modifier.pointerInput(scrollState, zoom) {
|
||||||
|
awaitEachGesture {
|
||||||
|
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||||
|
var claimed = false
|
||||||
|
var slop = 1f
|
||||||
|
// Scroll the layout could not give us yet, carried to the next frame
|
||||||
|
// (see anchoredScroll).
|
||||||
|
var pending = 0f
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||||
|
if (event.changes.none { it.pressed }) break
|
||||||
|
if (event.changes.count { it.pressed } >= 2) {
|
||||||
|
val step = event.calculateZoom()
|
||||||
|
if (!claimed) {
|
||||||
|
slop *= step
|
||||||
|
if (abs(slop - 1f) >= PINCH_SLOP) {
|
||||||
|
claimed = true
|
||||||
|
zoom.beginPinch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (claimed) {
|
||||||
|
val old = currentHourHeight.value.toPx()
|
||||||
|
val new = pinchedHourHeightPx(
|
||||||
|
target = old * step,
|
||||||
|
fillPx = fillHourHeight(currentViewport.value).toPx(),
|
||||||
|
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
|
||||||
|
)
|
||||||
|
// Half a pixel, not equality: the height round-trips
|
||||||
|
// through dp and back, and an exact test would read
|
||||||
|
// the float noise that comes back as a scale change
|
||||||
|
// and feed the scroll a delta on every frame of a
|
||||||
|
// held pinch.
|
||||||
|
if (abs(new - old) >= 0.5f) {
|
||||||
|
val centroidY = event.calculateCentroid(useCurrent = true).y
|
||||||
|
pending += anchoredScroll(scrollState.value, centroidY, old, new) -
|
||||||
|
scrollState.value
|
||||||
|
zoom.pinchTo(new.toDp())
|
||||||
|
}
|
||||||
|
pending -= scrollState.dispatchRawDelta(pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hold the gesture to the end once it has become a pinch:
|
||||||
|
// letting go the moment a finger lifts would turn the tail of
|
||||||
|
// a zoom into a scroll, and the taps underneath into a new event.
|
||||||
|
if (claimed) event.changes.forEach { if (it.pressed) it.consume() }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// In a finally because the gesture can also end by having its
|
||||||
|
// pointer node disposed mid-pinch — the timeline swapping out
|
||||||
|
// under the fingers. The zoom outlives that node, and a pinch
|
||||||
|
// left open would make it ignore every later Settings change.
|
||||||
|
if (claimed) zoom.endPinch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a pinch aiming at [target] px per hour actually lands: clamped between
|
||||||
|
* "the whole day fills the screen" ([fillPx]) and [maxPx], then rounded to a
|
||||||
|
* whole pixel.
|
||||||
|
*
|
||||||
|
* The rounding is not cosmetic. The hour gutter is 24 stacked boxes one hour
|
||||||
|
* tall, and each rounds its own height to whole pixels, while the hour lines and
|
||||||
|
* event blocks are drawn at the fractional height — so a height of 56.4px lays
|
||||||
|
* the labels out at 56px and leaves the 23:00 label ~9px above the line it
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* The bounds themselves are pulled onto that grid too, each in the direction
|
||||||
|
* 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
|
||||||
|
* pinch can be held against but never actually land on, and the difference feeds
|
||||||
|
* the focal anchor a scroll correction on every frame the fingers sit still.
|
||||||
|
*/
|
||||||
|
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
||||||
|
// 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.
|
||||||
|
target.roundToInt().toFloat()
|
||||||
|
.coerceAtMost(floor(maxPx))
|
||||||
|
.coerceAtLeast(ceil(fillPx))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||||
|
* hour height changes from [oldHourPx] to [newHourPx].
|
||||||
|
*
|
||||||
|
* Anchoring on the fingers is what makes a zoom feel like the day is being
|
||||||
|
* stretched rather than replaced: without it, zooming in on the evening walks
|
||||||
|
* the evening off the bottom of the screen.
|
||||||
|
*
|
||||||
|
* The caller may not get all of this at once — the content only grows on the
|
||||||
|
* next layout pass, so scrolling further down than the *current* content allows
|
||||||
|
* is refused — which is why the shortfall is carried over and re-offered.
|
||||||
|
*/
|
||||||
|
internal fun anchoredScroll(
|
||||||
|
scroll: Int,
|
||||||
|
centroidY: Float,
|
||||||
|
oldHourPx: Float,
|
||||||
|
newHourPx: Float,
|
||||||
|
): Float = ((scroll + centroidY) / oldHourPx) * newHourPx - centroidY
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the fingers must spread or close before the pinch takes over. Small
|
||||||
|
* enough to feel immediate, wide enough that the two-finger scroll a user meant
|
||||||
|
* as a scroll stays one.
|
||||||
|
*/
|
||||||
|
private const val PINCH_SLOP = 0.08f
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The viewport a view preview pretends to be: a phone's width by the height a
|
||||||
|
* calendar view gets under the top bar. Scaling every preview from one fixed
|
||||||
|
* height keeps them comparable — each shows the same slice of screen.
|
||||||
|
*/
|
||||||
|
internal val PREVIEW_VIEWPORT_HEIGHT = 440.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders [content] at a full phone viewport and shrinks the finished layout
|
||||||
|
* into a box [height] tall, for the settings and onboarding choosers. Callers
|
||||||
|
* pass the *real* view composables, so a preview cannot drift from what it
|
||||||
|
* depicts.
|
||||||
|
*
|
||||||
|
* The [layout] below ignores the incoming constraints so the view measures at a
|
||||||
|
* phone viewport, and a [graphicsLayer] scale shrinks the result.
|
||||||
|
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
|
||||||
|
* overflows its constraints, leaving only the bottom-right corner in the clip.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ScaledViewPreview(
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
|
val scale = height.value / PREVIEW_VIEWPORT_HEIGHT.value
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clipToBounds()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// A preview is a picture, not a control: swallow touches before the
|
||||||
|
// view's own clickables see them, and give screen readers one label
|
||||||
|
// instead of six weeks of day cells.
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||||
|
.forEach { it.consume() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
// Measure at a full phone viewport but report the scaled size, so the
|
||||||
|
// node occupies exactly what it draws.
|
||||||
|
.layout { measurable, _ ->
|
||||||
|
val fullWidth = screenWidth.roundToPx()
|
||||||
|
val fullHeight = PREVIEW_VIEWPORT_HEIGHT.roundToPx()
|
||||||
|
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||||
|
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||||
|
placeable.place(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
transformOrigin = TransformOrigin(0f, 0f)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxSize()) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stage every preview picker sets its preview on, cross-fading as [selected]
|
||||||
|
* changes. A preview draws the app's own surface, so a tonal plate gives it the
|
||||||
|
* edge it would otherwise lack on a chooser screen; the plate takes the width
|
||||||
|
* and outer radius of the grouped rows underneath so the two read as one stack.
|
||||||
|
*
|
||||||
|
* Only vertical spacing is the caller's to pass — the horizontal inset is the
|
||||||
|
* rows' own, and the alignment is the point.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun <T> ViewPreviewFrame(
|
||||||
|
selected: T,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable (T) -> Unit,
|
||||||
|
) {
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = GroupedListInset)
|
||||||
|
.clip(FRAME_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||||
|
.padding(FRAME_INSET),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
targetState = selected,
|
||||||
|
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||||
|
label = label,
|
||||||
|
) { shown ->
|
||||||
|
// Each preview renders the real view at phone size and scales it
|
||||||
|
// down, so its own corners are square — the plate rounds them.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(PREVIEW_SHAPE)
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.clipToBounds(),
|
||||||
|
) {
|
||||||
|
content(shown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FRAME_INSET = 8.dp
|
||||||
|
|
||||||
|
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
|
||||||
|
private val FRAME_SHAPE = RoundedCornerShape(22.dp)
|
||||||
|
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||||
@@ -42,9 +42,11 @@ import androidx.compose.material3.rememberDrawerState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -53,14 +55,19 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -68,12 +75,28 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
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.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
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.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.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.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
@@ -83,13 +106,22 @@ import de.jeanlucmakiola.calendula.ui.common.next
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.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.formatMinuteOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -100,18 +132,11 @@ import kotlin.time.Clock
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
|
||||||
private val GUTTER_WIDTH = 48.dp
|
|
||||||
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
|
||||||
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
|
||||||
* 4dp inset + 24dp half icon button), matching the week view. */
|
|
||||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
|
||||||
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
/** Total all-day strip height for the day (0 when there are no all-day events). */
|
||||||
private fun DayUiState.Success.allDayStripHeight(): Dp {
|
internal fun DayUiState.Success.allDayStripHeight(): Dp {
|
||||||
if (allDay.isEmpty()) return 0.dp
|
if (allDay.isEmpty()) return 0.dp
|
||||||
val lanes = allDay.maxOf { it.lane } + 1
|
val lanes = allDay.maxOf { it.lane } + 1
|
||||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||||
@@ -195,6 +220,7 @@ fun DayScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = date,
|
currentDate = date,
|
||||||
|
drawerState = drawerState,
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -221,6 +247,7 @@ fun DayScreen(
|
|||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -264,7 +291,6 @@ private fun DayContent(
|
|||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -275,11 +301,9 @@ private fun DayContent(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
val maxV = scrollState.maxValue
|
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||||
val target = with(density) {
|
// centring the range centres midday at whatever hour height is in force.
|
||||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
}.coerceIn(0, maxV)
|
|
||||||
scrollState.scrollTo(target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||||
@@ -295,42 +319,67 @@ private fun DayContent(
|
|||||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
|
|
||||||
AnimatedContent(
|
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||||
targetState = state,
|
// floating block inside the outgoing page.
|
||||||
modifier = modifier.then(swipeModifier),
|
val dragController = rememberTimelineDragController()
|
||||||
contentKey = { s ->
|
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) {
|
when (s) {
|
||||||
is DayUiState.Success -> "success-${s.date}"
|
DayUiState.Loading -> DayLoading()
|
||||||
is DayUiState.Failure -> "failure-${s.reason}"
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
DayUiState.Loading -> "loading"
|
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()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
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,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onCreateAt = onCreateAt,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
TimelineDragOverlay(dragController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DaySuccess(
|
internal fun DaySuccess(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
topSectionColor: Color,
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// All-day strip collapses to nothing when the day has no all-day events,
|
// All-day strip collapses to nothing when the day has no all-day events,
|
||||||
@@ -349,8 +398,10 @@ private fun DaySuccess(
|
|||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +415,7 @@ private fun DayTopBar(
|
|||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
@@ -371,9 +423,10 @@ private fun DayTopBar(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
CalendarTitleButton(
|
||||||
text = formatDayTitle(date, locale, currentYear),
|
title = formatDayTitle(date, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
currentDate = date,
|
||||||
|
onJumpToDate = onJumpToDate,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -420,7 +473,11 @@ private fun AllDayStrip(
|
|||||||
// Height is hoisted + animated so it resizes smoothly; padding sits
|
// Height is hoisted + animated so it resizes smoothly; padding sits
|
||||||
// inside it so the content area is lanes * row height.
|
// inside it so the content area is lanes * row height.
|
||||||
.height(height)
|
.height(height)
|
||||||
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
.padding(
|
||||||
|
top = ALL_DAY_VERTICAL_PADDING,
|
||||||
|
bottom = ALL_DAY_VERTICAL_PADDING,
|
||||||
|
end = TIMELINE_CONTENT_END_INSET,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
// Keep the gutter-width offset so the bars line up with the day column.
|
// Keep the gutter-width offset so the bars line up with the day column.
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
@@ -474,6 +531,7 @@ private fun AllDayBar(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,66 +540,74 @@ private fun AllDayBar(
|
|||||||
private fun Timeline(
|
private fun Timeline(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val locale = currentLocale()
|
val density = LocalDensity.current
|
||||||
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
// 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
|
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position.
|
// soft corners are permanent at any scroll position.
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||||
// labels centre on the top bar hamburger, matching the week view.
|
// labels centre on the top bar hamburger, matching the week view.
|
||||||
Column(
|
HourGutter(
|
||||||
modifier = Modifier
|
scrollState = scrollState,
|
||||||
.width(GUTTER_WIDTH)
|
hourHeight = hourHeight,
|
||||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
dragController = dragController,
|
||||||
.fillMaxHeight()
|
)
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
(0 until 24).forEach { h ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(HOUR_HEIGHT),
|
|
||||||
) {
|
|
||||||
if (h > 0) {
|
|
||||||
Text(
|
|
||||||
text = formatHourLabel(h, use24Hour, locale),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.offset(y = (-6).dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState)
|
||||||
|
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||||
) {
|
) {
|
||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
blocks = state.timed,
|
blocks = state.timed,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
date = state.date,
|
date = state.date,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
hourHeight = hourHeight,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight),
|
.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
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,13 +620,26 @@ private fun DayColumnCard(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
hourHeight: Dp,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||||
val showHourLines = LocalShowHourLines.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
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(
|
Card(
|
||||||
// Plain rectangular column — the soft corners come from the outer
|
// Plain rectangular column — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -581,31 +660,51 @@ private fun DayColumnCard(
|
|||||||
// only fires on the column background. Snaps to the tapped hour.
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
currentOnCreateAt.value(
|
||||||
onCreateAt(date, hour * 60)
|
date,
|
||||||
|
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val colWidth = maxWidth
|
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 ->
|
blocks.forEach { block ->
|
||||||
val laneWidth = colWidth / block.laneCount
|
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
key(block.event.eventId, ordinal) {
|
||||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
val laneWidth = colWidth / block.laneCount
|
||||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
EventBlock(
|
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||||
block = block,
|
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||||
dark = dark,
|
val place = animatedBlockPlacement(
|
||||||
onClick = { onEventClick(block.event) },
|
x = laneWidth * block.lane,
|
||||||
modifier = Modifier
|
y = top,
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
width = laneWidth,
|
||||||
.width(laneWidth)
|
height = height,
|
||||||
.height(height)
|
)
|
||||||
.padding(horizontal = 1.dp),
|
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.
|
// Current-time line, on top of the events, only on today's column.
|
||||||
if (date == today) {
|
if (date == today) {
|
||||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
NowLine(date = date, hourHeight = hourHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,7 +714,11 @@ private fun DayColumnCard(
|
|||||||
private fun EventBlock(
|
private fun EventBlock(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
height: Dp,
|
||||||
|
date: LocalDate,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -623,31 +726,67 @@ private fun EventBlock(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val showTime = block.endMin - block.startMin >= 45
|
val 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 soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val 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(
|
Box(
|
||||||
modifier = modifier
|
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))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.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)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics {
|
||||||
|
contentDescription = "$title, $timeLabel"
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
if (showTitle) {
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
maxLines = if (showTime) 1 else 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
|
||||||
)
|
|
||||||
if (showTime) {
|
|
||||||
Text(
|
Text(
|
||||||
text = timeLabel,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = 1,
|
maxLines = if (showTime) 1 else 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill, alpha = 0.6f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTime) {
|
||||||
|
BlockTimeLabel(
|
||||||
|
label = timeLabel,
|
||||||
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,17 +795,27 @@ private fun EventBlock(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayLoading() {
|
private fun DayLoading() {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val scale = LocalTimelineZoom.current.scale
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||||
Box(
|
// doesn't resize the moment the real day arrives.
|
||||||
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxSize()
|
||||||
.height(totalHeight)
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
.padding(horizontal = 2.dp)
|
.verticalScroll(scrollState),
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
) {
|
||||||
)
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(totalHeight)
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -100,6 +100,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
import de.jeanlucmakiola.calendula.ui.common.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.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
@@ -386,7 +387,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
val instance = detail.instance
|
val instance = detail.instance
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val locale = currentDetailLocale()
|
val locale = currentDetailLocale()
|
||||||
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|||||||
@@ -48,9 +48,14 @@ class EventDetailViewModel @Inject constructor(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _target = MutableStateFlow<Target?>(null)
|
private val _target = MutableStateFlow<Target?>(null)
|
||||||
// Bumped by retry() to re-run the load for the same target.
|
// Bumped by retry() and by re-opening the target already shown, to re-run
|
||||||
|
// 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()
|
||||||
|
|
||||||
@@ -58,11 +63,14 @@ 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 {
|
||||||
emit(EventDetailUiState.Loading)
|
if (loadedTarget != target) emit(EventDetailUiState.Loading)
|
||||||
emit(loadDetail(target))
|
val loaded = loadDetail(target)
|
||||||
|
loadedTarget = target.takeIf { loaded is EventDetailUiState.Success }
|
||||||
|
emit(loaded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,9 +86,15 @@ 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) {
|
||||||
_target.value = Target(eventId, beginMillis, endMillis)
|
val target = 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. */
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ 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
|
||||||
@@ -127,12 +128,14 @@ 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.data.calendar.CalendarColorPalette
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
||||||
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.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||||
@@ -262,13 +265,16 @@ 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)
|
snackbarHostState.showSnackbar(saveFailedMessage, duration = SnackbarDuration.Long)
|
||||||
}
|
}
|
||||||
SaveUiState.NeedsPermission -> {
|
SaveUiState.NeedsPermission -> {
|
||||||
viewModel.consumeSaveResult()
|
viewModel.consumeSaveResult()
|
||||||
snackbarHostState.showSnackbar(writeDeniedMessage)
|
snackbarHostState.showSnackbar(writeDeniedMessage, duration = SnackbarDuration.Long)
|
||||||
}
|
}
|
||||||
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
// AwaitingScope/AwaitingConflict/Gone render as dialogs below.
|
||||||
else -> Unit
|
else -> Unit
|
||||||
@@ -451,30 +457,11 @@ private fun SaveScopeDialog(
|
|||||||
onSelect: (RecurringWriteScope) -> Unit,
|
onSelect: (RecurringWriteScope) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
AlertDialog(
|
RecurringScopeDialog(
|
||||||
onDismissRequest = onDismiss,
|
title = stringResource(R.string.event_edit_recurring_title),
|
||||||
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
|
onSelect = onSelect,
|
||||||
text = {
|
onDismiss = onDismiss,
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
allowOccurrence = !recurrenceChanged,
|
||||||
if (!recurrenceChanged) {
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_occurrence),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_following),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
|
||||||
)
|
|
||||||
OptionCard(
|
|
||||||
label = stringResource(R.string.event_delete_option_series),
|
|
||||||
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +550,7 @@ private fun EventEditContent(
|
|||||||
// The accent ties the form to the detail screen's design language: the
|
// The accent ties the form to the detail screen's design language: the
|
||||||
// bar under the title takes the target calendar's colour.
|
// bar under the title takes the target calendar's colour.
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
|
val accent = selectedCalendar?.let { eventAccent(it.color, dark, soften) }
|
||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
@@ -9,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
@@ -51,28 +53,40 @@ import kotlinx.datetime.toInstant
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val TAG = "EventEdit"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||||
* sources want different reminder handling (#49).
|
* want different reminder handling (#49), and differ in whether they own the
|
||||||
|
* event's length ([ownsDuration], #54) — one that does is never restretched to the
|
||||||
|
* default-duration setting.
|
||||||
*/
|
*/
|
||||||
enum class ImportSource {
|
enum class ImportSource(internal val ownsDuration: Boolean) {
|
||||||
/**
|
/**
|
||||||
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
* An external `ACTION_INSERT` intent that named both ends (another app/widget,
|
||||||
* It carries no reminders of its own, so the settings default is applied
|
* e.g. Google Maps), or an in-app duplicate. It carries no reminders of its
|
||||||
* automatically, exactly like an in-app new event.
|
* own, so the settings default is applied automatically, exactly like an
|
||||||
|
* in-app new event; its span, though, is the caller's.
|
||||||
*/
|
*/
|
||||||
Insert,
|
Insert(ownsDuration = true),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `ACTION_INSERT` that named only a start. Reminders behave as for
|
||||||
|
* [Insert], but the length is ours to pick, so the default-duration setting
|
||||||
|
* applies just like an in-app new event.
|
||||||
|
*/
|
||||||
|
InsertOpenEnded(ownsDuration = false),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
||||||
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
||||||
* rather than silently applied or suppressed.
|
* rather than silently applied or suppressed. Its span is the file's.
|
||||||
*/
|
*/
|
||||||
File,
|
File(ownsDuration = true),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +127,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||||
// their choice. Reset with the form.
|
// their choice. Reset with the form.
|
||||||
private val _remindersTouched = MutableStateFlow(false)
|
private val _remindersTouched = MutableStateFlow(false)
|
||||||
|
// Same freeze for the default duration (#54): once the user has set an end
|
||||||
|
// time by hand, switching calendars no longer stretches the event.
|
||||||
|
private val _durationTouched = MutableStateFlow(false)
|
||||||
// A one-time offer, raised when a .ics import opens, to replace the file's
|
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||||
// reminders with the settings default (#49). Null while there's nothing to ask.
|
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||||
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||||
@@ -282,8 +299,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||||
* from midnight) anchors the start when the form is opened by tapping a slot
|
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||||
* in the day/week grid; without it the default is the next full hour (today)
|
* in the day/week grid; without it the default is the next full hour (today)
|
||||||
* or 09:00 (any other day). No-op when a form is already open, so user input
|
* or 09:00 (any other day). The event opens an hour long and is stretched to
|
||||||
* survives configuration changes; [reset] clears it when the screen closes.
|
* the configured default by [applyDefaultDuration], like the reminders.
|
||||||
|
* No-op when a form is already open, so user input survives configuration
|
||||||
|
* changes; [reset] clears it when the screen closes.
|
||||||
*/
|
*/
|
||||||
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
||||||
if (_form.value != null) return
|
if (_form.value != null) return
|
||||||
@@ -300,9 +319,11 @@ class EventEditViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
else -> LocalDateTime(date, LocalTime(9, 0))
|
else -> LocalDateTime(date, LocalTime(9, 0))
|
||||||
}
|
}
|
||||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
|
||||||
|
.toLocalDateTime(zone)
|
||||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
|
applyDefaultDuration()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -321,6 +342,12 @@ class EventEditViewModel @Inject constructor(
|
|||||||
* settings default is configured and differs, [importReminderPrompt] offers
|
* settings default is configured and differs, [importReminderPrompt] offers
|
||||||
* to swap it in rather than silently deciding for the user.
|
* to swap it in rather than silently deciding for the user.
|
||||||
*
|
*
|
||||||
|
* The length is frozen the same way: a source that named the event's end
|
||||||
|
* ([ImportSource.ownsDuration]) keeps it, so the default-duration setting
|
||||||
|
* never silently rewrites a span someone else chose; only an
|
||||||
|
* [ImportSource.InsertOpenEnded] intent — a start with no end — gets the
|
||||||
|
* default applied.
|
||||||
|
*
|
||||||
* No-op when a form is already open, so the prefill survives configuration
|
* No-op when a form is already open, so the prefill survives configuration
|
||||||
* changes.
|
* changes.
|
||||||
*/
|
*/
|
||||||
@@ -328,8 +355,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
if (_form.value != null || _editTarget.value != null) return
|
if (_form.value != null || _editTarget.value != null) return
|
||||||
_revealed.value = form.populatedFields()
|
_revealed.value = form.populatedFields()
|
||||||
_form.value = form
|
_form.value = form
|
||||||
|
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
|
||||||
when (source) {
|
when (source) {
|
||||||
ImportSource.Insert ->
|
ImportSource.Insert, ImportSource.InsertOpenEnded ->
|
||||||
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||||
else applyDefaultReminder()
|
else applyDefaultReminder()
|
||||||
|
|
||||||
@@ -358,7 +386,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||||
_form.value = form.copy(reminders = reminders)
|
// Write through update(): the duration default resolves in parallel,
|
||||||
|
// so both must compose onto the current form, not a pre-suspend copy.
|
||||||
|
update { it.copy(reminders = reminders) }
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
if (reminders.isNotEmpty()) {
|
if (reminders.isNotEmpty()) {
|
||||||
@@ -377,6 +407,39 @@ class EventEditViewModel @Inject constructor(
|
|||||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||||
}.first()
|
}.first()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stretch a new timed event to the configured default length (#54) — the
|
||||||
|
* resolved calendar's per-calendar duration, otherwise the global default —
|
||||||
|
* keeping its start put. No-op while editing an existing event, on an all-day
|
||||||
|
* event (which is date-anchored), or once the user has set an end time by
|
||||||
|
* hand. [calendarId] short-circuits the resolution after a calendar switch;
|
||||||
|
* null resolves it as the form does.
|
||||||
|
*/
|
||||||
|
private fun applyDefaultDuration(calendarId: Long? = null) {
|
||||||
|
if (_editTarget.value != null || _durationTouched.value) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val global = settingsPrefs.defaultEventDurationMinutes.first()
|
||||||
|
val overrides = settingsPrefs.perCalendarEventDuration.first()
|
||||||
|
// Only resolve the calendar when an override could actually apply —
|
||||||
|
// that read waits on the provider query, and the stretch would then
|
||||||
|
// land after the user can already see (and accept) the end time.
|
||||||
|
val targetId = when {
|
||||||
|
overrides.isEmpty() -> null
|
||||||
|
else -> calendarId ?: resolvedCalendarId.first()
|
||||||
|
}
|
||||||
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
|
if (_editTarget.value != null || _durationTouched.value) return@launch
|
||||||
|
if (_form.value?.isAllDay != false) return@launch
|
||||||
|
val duration = resolveDefaultEventDuration(global, overrides, targetId)
|
||||||
|
val zone = TimeZone.currentSystemDefault()
|
||||||
|
update { form ->
|
||||||
|
form.copy(
|
||||||
|
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A `.ics` import respects the file's reminders, but an event opened from a
|
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||||
* file often has none while the user still expects their configured default.
|
* file often has none while the user still expects their configured default.
|
||||||
@@ -455,6 +518,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
_editTarget.value = null
|
_editTarget.value = null
|
||||||
_loadFailed.value = false
|
_loadFailed.value = false
|
||||||
_remindersTouched.value = false
|
_remindersTouched.value = false
|
||||||
|
_durationTouched.value = false
|
||||||
_importReminderPrompt.value = null
|
_importReminderPrompt.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +543,9 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// The default reminder differs for all-day vs timed; re-apply the
|
// The default reminder differs for all-day vs timed; re-apply the
|
||||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||||
applyDefaultReminder()
|
applyDefaultReminder()
|
||||||
|
// Coming back out of all-day re-applies the default length (guarded);
|
||||||
|
// going all-day is a no-op, since dates carry no duration.
|
||||||
|
applyDefaultDuration()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -500,9 +567,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun setCalendar(id: Long) {
|
fun setCalendar(id: Long) {
|
||||||
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||||
// A fresh event re-inherits the new calendar's default reminder unless
|
// A fresh event re-inherits the new calendar's default reminder and
|
||||||
// the user has already hand-edited it (guarded inside).
|
// length unless the user has already hand-edited them (guarded inside).
|
||||||
applyDefaultReminder(id)
|
applyDefaultReminder(id)
|
||||||
|
applyDefaultDuration(id)
|
||||||
}
|
}
|
||||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||||
@@ -559,8 +627,18 @@ class EventEditViewModel @Inject constructor(
|
|||||||
/** Moving the start drags the end along, preserving the duration. */
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||||
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||||
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
|
||||||
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
// Setting an end by hand is the user picking a length: it freezes the
|
||||||
|
// default duration, so a later calendar switch keeps their span (#54).
|
||||||
|
fun setEndDate(date: LocalDate) {
|
||||||
|
_durationTouched.value = true
|
||||||
|
update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEndTime(time: LocalTime) {
|
||||||
|
_durationTouched.value = true
|
||||||
|
update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and write. Saving a dirty recurring event pauses in
|
* Validate and write. Saving a dirty recurring event pauses in
|
||||||
@@ -676,7 +754,15 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
|
||||||
@@ -73,7 +74,12 @@ private fun FilterList(
|
|||||||
},
|
},
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
modifier = Modifier.padding(
|
||||||
|
start = GroupedListInset,
|
||||||
|
end = GroupedListInset,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = 4.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
group.calendars.forEachIndexed { index, cal ->
|
group.calendars.forEachIndexed { index, cal ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -103,7 +109,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
|||||||
repeat(4) {
|
repeat(4) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 28.dp)
|
.padding(horizontal = GroupedListInset)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(36.dp)
|
.height(36.dp)
|
||||||
.background(
|
.background(
|
||||||
@@ -129,6 +135,6 @@ private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier)
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 28.dp, vertical = 24.dp),
|
.padding(horizontal = GroupedListInset, vertical = 24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.layout.boundsInRoot
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** One week row's live geometry, republished on every layout while it is on screen. */
|
||||||
|
class MonthRowGeometry(
|
||||||
|
val days: List<LocalDate>,
|
||||||
|
/** The row's day-column box — the space chip offsets are measured in. */
|
||||||
|
val cell: LayoutCoordinates,
|
||||||
|
/** The lane band inside that box, where the chips themselves are seated. */
|
||||||
|
val band: LayoutCoordinates?,
|
||||||
|
val columnWidthPx: Float,
|
||||||
|
val laneHeightPx: Float,
|
||||||
|
val laneCount: Int,
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
val isRtl: Boolean,
|
||||||
|
/** What this row currently draws in [lane] of column `col`, or null. */
|
||||||
|
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
||||||
|
) {
|
||||||
|
/** Root top-left of the chip seated in [lane] of column [col]. */
|
||||||
|
fun seat(col: Int, lane: Int): Offset? {
|
||||||
|
val band = band?.takeIf { it.isAttached } ?: return null
|
||||||
|
val origin = band.positionInRoot()
|
||||||
|
val column = if (isRtl) days.lastIndex - col else col
|
||||||
|
return Offset(origin.x + column * columnWidthPx, origin.y + lane * laneHeightPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where this row seats [event] on [date], or null when it doesn't hold it —
|
||||||
|
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
||||||
|
*/
|
||||||
|
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
||||||
|
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
||||||
|
val lane = (0 until laneCount).firstOrNull { lane ->
|
||||||
|
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
||||||
|
} ?: return null
|
||||||
|
return seat(col, lane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither id
|
||||||
|
* alone will do: re-read instances get new instance ids, and a single-occurrence
|
||||||
|
* move writes a new event id — the title survives both.
|
||||||
|
*/
|
||||||
|
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
||||||
|
chip.eventId == moved.eventId || chip.title == moved.title
|
||||||
|
|
||||||
|
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
|
||||||
|
data class MonthChipDrag(
|
||||||
|
val event: EventInstance,
|
||||||
|
/** The day whose column the chip was grabbed in — the drag is a delta from it. */
|
||||||
|
val grabDate: LocalDate,
|
||||||
|
val targetDate: LocalDate?,
|
||||||
|
val topLeftInRoot: Offset,
|
||||||
|
val sizePx: IntSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||||
|
data class MonthChipDrop(
|
||||||
|
val event: EventInstance,
|
||||||
|
val grabDate: LocalDate,
|
||||||
|
val targetDate: LocalDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hoisted drag state for the month grid (#68). Every visible week row registers
|
||||||
|
* itself so a chip can be carried across rows, the target resolved against
|
||||||
|
* whichever registered row the finger is over. Rows are keyed by an identity
|
||||||
|
* token rather than a date: the continuous style can show the same week twice.
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class MonthDragController {
|
||||||
|
private val rows = LinkedHashMap<Any, MonthRowGeometry>()
|
||||||
|
|
||||||
|
var drag: MonthChipDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dropped chip, held on the day it landed on while the write runs — the
|
||||||
|
* grid behind it still shows the old day until the query re-reads.
|
||||||
|
*/
|
||||||
|
var settling: MonthChipDrag? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which chip is lifted, and whether anything is. Separate snapshot state from
|
||||||
|
* [drag], which changes every frame, so the chips and rows that only need
|
||||||
|
* "am I the ghost" don't recompose that often. Stays set through [settling].
|
||||||
|
*/
|
||||||
|
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the grid itself now draws the drop, in root coordinates — published
|
||||||
|
* by whichever week row ends up holding it. The copy is dropped wherever the
|
||||||
|
* finger was while the grid seats it in a lane, so without this it would
|
||||||
|
* cross-fade across the gap instead of gliding onto its seat.
|
||||||
|
*/
|
||||||
|
var settledInRoot: Offset? by mutableStateOf(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the drop's own chip is still standing in for the copy. Cleared at
|
||||||
|
* [handOver] rather than [release], or the chip stays dim under a copy that
|
||||||
|
* has already faded and brightens afterwards: a visible dip.
|
||||||
|
*/
|
||||||
|
private var settledGhost: Boolean by mutableStateOf(false)
|
||||||
|
|
||||||
|
/** Whether a finger is on a chip right now — [settling] is not dragging. */
|
||||||
|
var isDragging: Boolean by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last drop this controller made, kept past [release] for [beginUndo] —
|
||||||
|
* the confirmation chip carries Undo long after the copy is gone.
|
||||||
|
*/
|
||||||
|
private var undoable: MonthChipDrag? = null
|
||||||
|
|
||||||
|
private var event: EventInstance? = null
|
||||||
|
private var grabDate: LocalDate? = null
|
||||||
|
private var grab = Offset.Zero
|
||||||
|
private var pointer = Offset.Zero
|
||||||
|
private var sizePx = IntSize.Zero
|
||||||
|
|
||||||
|
fun putRow(token: Any, geometry: MonthRowGeometry) {
|
||||||
|
rows[token] = geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeRow(token: Any) {
|
||||||
|
rows.remove(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun begin(
|
||||||
|
event: EventInstance,
|
||||||
|
grabDate: LocalDate,
|
||||||
|
pointerInRoot: Offset,
|
||||||
|
chipInRoot: Offset,
|
||||||
|
size: IntSize,
|
||||||
|
) {
|
||||||
|
this.event = event
|
||||||
|
this.grabDate = grabDate
|
||||||
|
settling = null
|
||||||
|
isDragging = true
|
||||||
|
liftedInstanceId = event.instanceId
|
||||||
|
grab = pointerInRoot - chipInRoot
|
||||||
|
pointer = pointerInRoot
|
||||||
|
sizePx = size
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun move(pointerInRoot: Offset) {
|
||||||
|
pointer = pointerInRoot
|
||||||
|
recompute()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
event = null
|
||||||
|
grabDate = null
|
||||||
|
isDragging = false
|
||||||
|
liftedInstanceId = null
|
||||||
|
drag = null
|
||||||
|
settling = null
|
||||||
|
settledInRoot = null
|
||||||
|
settledGhost = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
||||||
|
* the seat the copy is on its way to, which must ghost until it gets there.
|
||||||
|
*/
|
||||||
|
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
|
||||||
|
val landed = settling?.takeIf { settledGhost } ?: return false
|
||||||
|
if (days == null || landed.targetDate !in days) return false
|
||||||
|
return isSameEvent(event, landed.event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the row keyed [token] whether it now seats the settled chip. Both
|
||||||
|
* copies of a week the continuous style shows twice will answer; the seat
|
||||||
|
* nearest where the chip was let go is the one the finger was over.
|
||||||
|
*/
|
||||||
|
fun noteSettled(token: Any) {
|
||||||
|
val landed = settling ?: return
|
||||||
|
val target = landed.targetDate ?: return
|
||||||
|
val at = rows[token]?.seatOf(landed.event, target) ?: return
|
||||||
|
val current = settledInRoot
|
||||||
|
val closer = current == null ||
|
||||||
|
abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
|
||||||
|
if (closer) settledInRoot = at
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a chip on the journey an undo's inverse write is about to make, so it
|
||||||
|
* travels back rather than teleporting. Writes nothing itself. False when
|
||||||
|
* there is no drop of this controller's left to undo, or the grid doesn't
|
||||||
|
* seat the moved event where the chip would have to start from.
|
||||||
|
*/
|
||||||
|
fun beginUndo(): Boolean {
|
||||||
|
val last = undoable ?: return false
|
||||||
|
undoable = null
|
||||||
|
val from = last.targetDate ?: return false
|
||||||
|
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
|
||||||
|
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
|
||||||
|
liftedInstanceId = last.event.instanceId
|
||||||
|
settledGhost = true
|
||||||
|
settledInRoot = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the drag, handing back where it landed — null when it never resolved,
|
||||||
|
* or when it landed back on the day it came from. A real drop keeps its chip
|
||||||
|
* on the target day as [settling] until [release], slid over to that day's
|
||||||
|
* column so it lands where the grid is about to draw it.
|
||||||
|
*/
|
||||||
|
fun finish(): MonthChipDrop? {
|
||||||
|
val landed = drag
|
||||||
|
val left = landed?.targetDate?.let(::columnLeft)
|
||||||
|
cancel()
|
||||||
|
val target = landed?.targetDate?.takeIf { it != landed.grabDate } ?: return null
|
||||||
|
settling = landed.copy(
|
||||||
|
topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y),
|
||||||
|
)
|
||||||
|
liftedInstanceId = landed.event.instanceId
|
||||||
|
settledGhost = true
|
||||||
|
undoable = settling
|
||||||
|
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop ghosting the source while the copy is still dissolving — un-ghosting
|
||||||
|
* at [release] instead leaves the chip dim and then brightens it.
|
||||||
|
*/
|
||||||
|
fun handOver() {
|
||||||
|
liftedInstanceId = null
|
||||||
|
settledGhost = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the copy, once it has faded into the grid's own chip. */
|
||||||
|
fun release() {
|
||||||
|
settling = null
|
||||||
|
settledInRoot = null
|
||||||
|
handOver()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Root x of [date]'s column, in whichever visible row shows that day. */
|
||||||
|
private fun columnLeft(date: LocalDate): Float? = rows.values.firstNotNullOfOrNull { row ->
|
||||||
|
val index = row.days.indexOf(date).takeIf { it >= 0 } ?: return@firstNotNullOfOrNull null
|
||||||
|
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
||||||
|
?: return@firstNotNullOfOrNull null
|
||||||
|
val column = if (row.isRtl) row.days.lastIndex - index else index
|
||||||
|
bounds.left + column * row.columnWidthPx
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recompute() {
|
||||||
|
val event = event ?: return
|
||||||
|
val grabbed = grabDate ?: return
|
||||||
|
val resolved = rows.values.firstNotNullOfOrNull { row ->
|
||||||
|
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
||||||
|
?: return@firstNotNullOfOrNull null
|
||||||
|
if (!bounds.contains(pointer) || row.columnWidthPx <= 0f) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val column = ((pointer.x - bounds.left) / row.columnWidthPx)
|
||||||
|
.toInt()
|
||||||
|
.coerceIn(0, row.days.lastIndex)
|
||||||
|
row.days[if (row.isRtl) row.days.lastIndex - column else column]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drag = MonthChipDrag(
|
||||||
|
event = event,
|
||||||
|
grabDate = grabbed,
|
||||||
|
// Off the grid entirely: keep the last cell it was over, so a
|
||||||
|
// wobble past the edge doesn't drop the target.
|
||||||
|
targetDate = resolved ?: drag?.targetDate,
|
||||||
|
topLeftInRoot = pointer - grab,
|
||||||
|
sizePx = sizePx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
|
||||||
|
|
||||||
|
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
||||||
@@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.VectorConverter
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
@@ -17,6 +19,8 @@ import androidx.compose.animation.togetherWith
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -59,9 +63,11 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -77,11 +83,37 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.LayoutCoordinates
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
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.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -101,6 +133,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
@@ -108,8 +141,10 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
@@ -124,6 +159,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -329,6 +365,7 @@ fun MonthScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
|
drawerState = drawerState,
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -350,10 +387,12 @@ fun MonthScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
title = topBarTitle,
|
title = topBarTitle,
|
||||||
|
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -379,13 +418,23 @@ fun MonthScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
// Hoisted above every style's grid: a dragged chip is drawn in an
|
||||||
|
// overlay, clear of the week row's and the list viewport's clips.
|
||||||
|
val chipDrag = rememberMonthDragController()
|
||||||
|
// Undo moves the event back, so the chip travels back too. The
|
||||||
|
// signal comes from the write, not from the chip that offers it.
|
||||||
|
OnUndoStarted { chipDrag.beginUndo() }
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
CompositionLocalProvider(
|
||||||
|
LocalDimCutoff provides dimCutoff,
|
||||||
|
LocalMonthDrag provides chipDrag,
|
||||||
|
) {
|
||||||
if (scrolling) {
|
if (scrolling) {
|
||||||
ContinuousMonthContent(
|
ContinuousMonthContent(
|
||||||
state = continuousState,
|
state = continuousState,
|
||||||
@@ -394,6 +443,7 @@ fun MonthScreen(
|
|||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
} else if (viewStyle == MonthViewStyle.Split) {
|
} else if (viewStyle == MonthViewStyle.Split) {
|
||||||
SplitMonthContent(
|
SplitMonthContent(
|
||||||
@@ -418,14 +468,108 @@ fun MonthScreen(
|
|||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
MonthDragOverlay(chipDrag)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to wait for a grid that never seats the drop — it landed in a day's
|
||||||
|
* "+N" overflow or on a day this month doesn't show, or the write failed.
|
||||||
|
*/
|
||||||
|
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
||||||
|
|
||||||
|
/** The chip in flight, drawn over the grid and following the finger. */
|
||||||
|
@Composable
|
||||||
|
private fun MonthDragOverlay(controller: MonthDragController) {
|
||||||
|
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
val dark = isSystemInDarkTheme()
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
|
val moveInFlight = moveInFlight()
|
||||||
|
// Here rather than beside the controller: this reads [drag], which changes
|
||||||
|
// every frame, and this composable is the one that is meant to.
|
||||||
|
DragSnapHaptics(controller.drag?.targetDate)
|
||||||
|
// Hold the landed chip until the write is done — including the time a
|
||||||
|
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
|
||||||
|
// it to its seat: it was dropped at whatever height the finger was at, while
|
||||||
|
// the grid seats it in a lane.
|
||||||
|
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||||
|
var gliding by remember(controller.settling) { mutableStateOf(false) }
|
||||||
|
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||||
|
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||||
|
val seat = controller.settledInRoot
|
||||||
|
LaunchedEffect(controller.settling, moveInFlight, seat) {
|
||||||
|
val landed = controller.settling
|
||||||
|
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||||
|
if (seat == null) {
|
||||||
|
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||||
|
} else {
|
||||||
|
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||||
|
gliding = true
|
||||||
|
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
||||||
|
delay(SETTLE_GRACE_MILLIS)
|
||||||
|
}
|
||||||
|
handingOver = true
|
||||||
|
controller.handOver()
|
||||||
|
delay(SETTLE_FADE_MILLIS.toLong())
|
||||||
|
controller.release()
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// A copy of a chip still in the tree behind it; announcing it again
|
||||||
|
// would duplicate the event for the drag's duration.
|
||||||
|
.clearAndSetSemantics { }
|
||||||
|
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||||
|
) {
|
||||||
|
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||||
|
// Landed: the copy sinks back to the grid's own plane while the write runs.
|
||||||
|
val lift by animateFloatAsState(
|
||||||
|
targetValue = if (controller.drag == null || reduceMotion) 0f else 1f,
|
||||||
|
label = "chip-lift",
|
||||||
|
)
|
||||||
|
val copyAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (handingOver) 0f else 1f,
|
||||||
|
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||||
|
label = "chip-handover",
|
||||||
|
)
|
||||||
|
MonthBar(
|
||||||
|
event = drag.event,
|
||||||
|
dark = dark,
|
||||||
|
continuesLeft = false,
|
||||||
|
continuesRight = false,
|
||||||
|
modifier = Modifier
|
||||||
|
// Absolute: these are root coordinates, and the direction-aware
|
||||||
|
// offset would mirror them across the screen in an RTL layout.
|
||||||
|
.absoluteOffset {
|
||||||
|
val at = if (gliding) glide.value else drag.topLeftInRoot
|
||||||
|
IntOffset(
|
||||||
|
(at.x - origin.x).roundToInt(),
|
||||||
|
(at.y - origin.y).roundToInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.width(with(density) { drag.sizePx.width.toDp() })
|
||||||
|
.height(with(density) { drag.sizePx.height.toDp() })
|
||||||
|
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = 1f + 0.04f * lift
|
||||||
|
scaleY = 1f + 0.04f * lift
|
||||||
|
shadowElevation = 8.dp.toPx() * lift
|
||||||
|
alpha = copyAlpha
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
clip = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthContent(
|
private fun MonthContent(
|
||||||
state: MonthUiState,
|
state: MonthUiState,
|
||||||
@@ -435,6 +579,7 @@ private fun MonthContent(
|
|||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
) {
|
) {
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
@@ -461,6 +606,7 @@ private fun MonthContent(
|
|||||||
state = s,
|
state = s,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,6 +625,7 @@ private fun ContinuousMonthContent(
|
|||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
// The scrolling styles get their own skeleton rather than the paged
|
// The scrolling styles get their own skeleton rather than the paged
|
||||||
@@ -493,6 +640,7 @@ private fun ContinuousMonthContent(
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ContinuousMonthGrid(
|
ContinuousMonthGrid(
|
||||||
@@ -500,6 +648,7 @@ private fun ContinuousMonthContent(
|
|||||||
listState = listState,
|
listState = listState,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,19 +658,22 @@ private fun ContinuousMonthContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthTopBar(
|
private fun MonthTopBar(
|
||||||
title: String,
|
title: String,
|
||||||
|
titleDate: LocalDate,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
CalendarTitleButton(
|
||||||
text = title,
|
title = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
currentDate = titleDate,
|
||||||
|
onJumpToDate = onJumpToDate,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -595,7 +747,8 @@ private val CELL_SHAPE = RoundedCornerShape(CELL_CORNER)
|
|||||||
|
|
||||||
/** Width of the split style's selected-day outline. */
|
/** Width of the split style's selected-day outline. */
|
||||||
private val SPLIT_SELECTION_STROKE = 1.5.dp
|
private val SPLIT_SELECTION_STROKE = 1.5.dp
|
||||||
private const val MAX_EVENT_ROWS = 3
|
/** Lanes of bars/pills a day cell draws before the rest become overflow dots. */
|
||||||
|
internal const val MAX_EVENT_ROWS = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Row height in the continuous grid. The paged grid divides the viewport between
|
* Row height in the continuous grid. The paged grid divides the viewport between
|
||||||
@@ -613,11 +766,15 @@ private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
|||||||
*/
|
*/
|
||||||
private val CONTINUOUS_MONTH_GAP = 20.dp
|
private val CONTINUOUS_MONTH_GAP = 20.dp
|
||||||
|
|
||||||
|
/** Gap between the weekday header and the seamless stream's first week row. */
|
||||||
|
private val DENSE_HEADER_GAP = 4.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MonthGrid(
|
internal fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
/** See [MonthWeekRow]'s `selected`: an anchor for the morph, never a mark. */
|
||||||
selected: LocalDate? = null,
|
selected: LocalDate? = null,
|
||||||
) {
|
) {
|
||||||
@@ -638,6 +795,7 @@ internal fun MonthGrid(
|
|||||||
inMonth = { it.month == month.month && it.year == month.year },
|
inMonth = { it.month == month.month && it.year == month.year },
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -668,6 +826,7 @@ internal fun ContinuousMonthGrid(
|
|||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val monthCount = remember { continuousMonthCount() }
|
val monthCount = remember { continuousMonthCount() }
|
||||||
@@ -695,6 +854,7 @@ internal fun ContinuousMonthGrid(
|
|||||||
today = state.today,
|
today = state.today,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -714,6 +874,7 @@ private fun ContinuousMonthBlock(
|
|||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
) {
|
) {
|
||||||
val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) }
|
val rowCount = remember(month, weekStart) { weekRowsInMonth(month, weekStart) }
|
||||||
Column(
|
Column(
|
||||||
@@ -736,6 +897,7 @@ private fun ContinuousMonthBlock(
|
|||||||
blankOutside = true,
|
blankOutside = true,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(CONTINUOUS_ROW_HEIGHT),
|
.height(CONTINUOUS_ROW_HEIGHT),
|
||||||
@@ -794,6 +956,7 @@ internal fun DenseMonthGrid(
|
|||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
|
||||||
@@ -801,10 +964,15 @@ internal fun DenseMonthGrid(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 8.dp),
|
// The gap under the weekday header is a real margin, not content
|
||||||
|
// padding: a lazy list scrolls its rows *through* the before-content
|
||||||
|
// padding, so scrolling to a week landed its row that far down with
|
||||||
|
// the tail of the previous one showing above it (#191).
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.padding(top = DENSE_HEADER_GAP),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 4.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
items(count = weekCount, key = { it }) { index ->
|
items(count = weekCount, key = { it }) { index ->
|
||||||
val week = state.weeksByIndex[index]
|
val week = state.weeksByIndex[index]
|
||||||
@@ -819,6 +987,7 @@ internal fun DenseMonthGrid(
|
|||||||
inMonth = { true },
|
inMonth = { true },
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
labelMonthOnFirst = true,
|
labelMonthOnFirst = true,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1046,6 +1215,9 @@ private fun SplitMonthBody(
|
|||||||
onSelectDay(it)
|
onSelectDay(it)
|
||||||
onSetExpanded(false)
|
onSetExpanded(false)
|
||||||
},
|
},
|
||||||
|
// A tapped chip is asking for that event, not for its day, so it
|
||||||
|
// opens the detail from here too rather than collapsing (#187).
|
||||||
|
onEventClick = onEventClick,
|
||||||
onCollapse = { onSetExpanded(false) },
|
onCollapse = { onSetExpanded(false) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -1146,6 +1318,7 @@ private fun SplitMonthExpanded(
|
|||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
swipeModifier: Modifier,
|
swipeModifier: Modifier,
|
||||||
onPickDay: (LocalDate) -> Unit,
|
onPickDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCollapse: () -> Unit,
|
onCollapse: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
@@ -1168,6 +1341,7 @@ private fun SplitMonthExpanded(
|
|||||||
state = s,
|
state = s,
|
||||||
showWeekNumbers = showWeekNumbers,
|
showWeekNumbers = showWeekNumbers,
|
||||||
onOpenDay = onPickDay,
|
onOpenDay = onPickDay,
|
||||||
|
onEventClick = onEventClick,
|
||||||
selected = sel,
|
selected = sel,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1473,7 +1647,7 @@ private fun SplitDots(
|
|||||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||||
.size(SPLIT_DOT_SIZE)
|
.size(SPLIT_DOT_SIZE)
|
||||||
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
.background(eventAccent(event.color, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (hidden.isNotEmpty()) {
|
if (hidden.isNotEmpty()) {
|
||||||
@@ -1648,7 +1822,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
|
|||||||
modifier = Modifier.padding(bottom = 8.dp),
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(DENSE_HEADER_GAP))
|
||||||
}
|
}
|
||||||
// More rows than a viewport holds; the clip takes the overflow.
|
// More rows than a viewport holds; the clip takes the overflow.
|
||||||
repeat(6) {
|
repeat(6) {
|
||||||
@@ -1696,6 +1870,7 @@ private fun MonthWeekRow(
|
|||||||
inMonth: (LocalDate) -> Boolean,
|
inMonth: (LocalDate) -> Boolean,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
blankOutside: Boolean = false,
|
blankOutside: Boolean = false,
|
||||||
labelMonthOnFirst: Boolean = false,
|
labelMonthOnFirst: Boolean = false,
|
||||||
@@ -1712,6 +1887,50 @@ private fun MonthWeekRow(
|
|||||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||||
val morphing = morphInFlight()
|
val morphing = morphInFlight()
|
||||||
|
|
||||||
|
// Drag to reschedule (#68). The chips can take no pointer input of their own
|
||||||
|
// — the full-bleed tap layer sits on top of them — so one detector on the
|
||||||
|
// row's day-column box hit-tests them geometrically instead.
|
||||||
|
val moveScope = LocalEventMove.current
|
||||||
|
val dragController = LocalMonthDrag.current
|
||||||
|
val rowToken = remember { Any() }
|
||||||
|
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||||
|
val cellCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
|
||||||
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
val dragging = dragController?.isDragging == true
|
||||||
|
DisposableEffect(rowToken, dragController) {
|
||||||
|
onDispose { dragController?.removeRow(rowToken) }
|
||||||
|
}
|
||||||
|
// Republished on layout *and* on every recomposition: the coordinates change
|
||||||
|
// only on the former, but what the row draws — which the controller reads to
|
||||||
|
// find a moved chip's seat — changes on the latter.
|
||||||
|
val publish = {
|
||||||
|
val cell = cellCoordinates[0]?.takeIf { it.isAttached }
|
||||||
|
if (dragController != null && cell != null) {
|
||||||
|
dragController.putRow(
|
||||||
|
rowToken,
|
||||||
|
MonthRowGeometry(
|
||||||
|
days = week.days,
|
||||||
|
cell = cell,
|
||||||
|
band = bandCoordinates[0],
|
||||||
|
columnWidthPx = cell.size.width / 7f,
|
||||||
|
laneHeightPx = rowHeightPx,
|
||||||
|
laneCount = MAX_EVENT_ROWS,
|
||||||
|
isRtl = isRtl,
|
||||||
|
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SideEffect { publish() }
|
||||||
|
// Once the grid holds the settled chip, tell the controller where this row
|
||||||
|
// has seated it, so the copy in flight can glide onto it. Keyed on the week,
|
||||||
|
// which is what changes when the re-read lands.
|
||||||
|
LaunchedEffect(week, dragController?.settling) {
|
||||||
|
dragController?.noteSettled(rowToken)
|
||||||
|
}
|
||||||
|
|
||||||
Row(modifier) {
|
Row(modifier) {
|
||||||
// Optional calendar-week gutter, sized so the seven day columns below
|
// Optional calendar-week gutter, sized so the seven day columns below
|
||||||
// divide the remaining width — the absolute bar offsets stay correct
|
// divide the remaining width — the absolute bar offsets stay correct
|
||||||
@@ -1727,7 +1946,21 @@ private fun MonthWeekRow(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight()
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
cellCoordinates[0] = coords
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
.then(
|
||||||
|
monthChipDragModifier(
|
||||||
|
week = week,
|
||||||
|
moveScope = moveScope,
|
||||||
|
controller = dragController,
|
||||||
|
band = bandCoordinates,
|
||||||
|
rowHeightPx = rowHeightPx,
|
||||||
|
isRtl = isRtl,
|
||||||
|
),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
val colW = maxWidth / 7
|
val colW = maxWidth / 7
|
||||||
|
|
||||||
@@ -1792,7 +2025,13 @@ private fun MonthWeekRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.then(if (morphing) Modifier else Modifier.clipToBounds()),
|
.onGloballyPositioned {
|
||||||
|
bandCoordinates[0] = it
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
// A dragged chip travels to another row, so the clip
|
||||||
|
// yields for it as it does for a morph.
|
||||||
|
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
|
||||||
) {
|
) {
|
||||||
// Spanning bars on their shared lanes.
|
// Spanning bars on their shared lanes.
|
||||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||||
@@ -1802,6 +2041,7 @@ private fun MonthWeekRow(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = span.continuesLeft,
|
continuesLeft = span.continuesLeft,
|
||||||
continuesRight = span.continuesRight,
|
continuesRight = span.continuesRight,
|
||||||
|
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * span.startCol,
|
x = colW * span.startCol,
|
||||||
@@ -1871,6 +2111,7 @@ private fun MonthWeekRow(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
continuesLeft = false,
|
continuesLeft = false,
|
||||||
continuesRight = false,
|
continuesRight = false,
|
||||||
|
days = listOf(d),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(
|
.offset(
|
||||||
x = colW * col,
|
x = colW * col,
|
||||||
@@ -1928,11 +2169,17 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tap layer: in month view a tap on any day opens that day. Padded and
|
// Tap layer: a tap on a chip opens that event, anything else opens the
|
||||||
|
// day (#187). The chips take no pointer input of their own — this
|
||||||
|
// layer covers them — so the lane under the finger is resolved
|
||||||
|
// geometrically, exactly as the drag pickup above does it; the down
|
||||||
|
// position is read on the initial pass, which consumes nothing and so
|
||||||
|
// leaves both the click and a pickup in flight untouched. Padded and
|
||||||
// clipped to the background pill so the ripple matches it. A blanked
|
// clipped to the background pill so the ripple matches it. A blanked
|
||||||
// cell isn't part of this month, so it takes no taps either.
|
// cell isn't part of this month, so it takes no taps either.
|
||||||
|
val downY = remember(week.days.size) { FloatArray(week.days.size) { NO_DOWN_Y } }
|
||||||
Row(Modifier.matchParentSize()) {
|
Row(Modifier.matchParentSize()) {
|
||||||
week.days.forEach { d ->
|
week.days.forEachIndexed { col, d ->
|
||||||
if (blankOutside && !inMonth(d)) {
|
if (blankOutside && !inMonth(d)) {
|
||||||
Spacer(Modifier.weight(1f).fillMaxHeight())
|
Spacer(Modifier.weight(1f).fillMaxHeight())
|
||||||
} else {
|
} else {
|
||||||
@@ -1940,9 +2187,33 @@ private fun MonthWeekRow(
|
|||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
.pointerInput(col) {
|
||||||
|
awaitEachGesture {
|
||||||
|
downY[col] = awaitFirstDown(
|
||||||
|
requireUnconsumed = false,
|
||||||
|
pass = PointerEventPass.Initial,
|
||||||
|
).position.y
|
||||||
|
}
|
||||||
|
}
|
||||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||||
.clip(CELL_SHAPE)
|
.clip(CELL_SHAPE)
|
||||||
.clickable { onOpenDay(d) },
|
.clickable {
|
||||||
|
// Cleared on read: a click with no fresh down
|
||||||
|
// (TalkBack, D-pad) would otherwise resolve the
|
||||||
|
// previous tap's position and reopen its chip.
|
||||||
|
val cellY = downY[col]
|
||||||
|
downY[col] = NO_DOWN_Y
|
||||||
|
val chip = week.chipAtCellY(
|
||||||
|
col = col,
|
||||||
|
cellY = cellY,
|
||||||
|
bandTopInCell = bandTopInCell(
|
||||||
|
cellCoordinates,
|
||||||
|
bandCoordinates,
|
||||||
|
),
|
||||||
|
rowHeightPx = rowHeightPx,
|
||||||
|
)
|
||||||
|
if (chip != null) onEventClick(chip) else onOpenDay(d)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1951,6 +2222,110 @@ private fun MonthWeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the event band sits below the top of the row's day-column box, or null
|
||||||
|
* while either is unmeasured. Read off the live coordinates rather than summed
|
||||||
|
* from the padding constants, so it can't drift from what the row actually drew.
|
||||||
|
*/
|
||||||
|
private fun bandTopInCell(
|
||||||
|
cell: Array<LayoutCoordinates?>,
|
||||||
|
band: Array<LayoutCoordinates?>,
|
||||||
|
): Float? {
|
||||||
|
val cellTop = cell[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null
|
||||||
|
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y ?: return null
|
||||||
|
return bandTop - cellTop
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in [cellY] for "no touch down recorded", which resolves to no chip.
|
||||||
|
*/
|
||||||
|
private const val NO_DOWN_Y = Float.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chip at [cellY] in column [col], where [cellY] is measured from the top of
|
||||||
|
* the row's day-column box. Null for a tap above the band (the day number), on an
|
||||||
|
* empty lane, or on the overflow dots — all of which mean "open the day", the
|
||||||
|
* dots included: their point is that the day holds more than fits.
|
||||||
|
*/
|
||||||
|
internal fun MonthWeek.chipAtCellY(
|
||||||
|
col: Int,
|
||||||
|
cellY: Float,
|
||||||
|
bandTopInCell: Float?,
|
||||||
|
rowHeightPx: Float,
|
||||||
|
): EventInstance? {
|
||||||
|
if (bandTopInCell == null || rowHeightPx <= 0f) return null
|
||||||
|
val bandY = cellY - bandTopInCell
|
||||||
|
if (bandY < 0f) return null
|
||||||
|
return chipAt(col, (bandY / rowHeightPx).toInt(), MAX_EVENT_ROWS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row-level pickup for month chips: resolves which chip the press landed on
|
||||||
|
* from the geometry the row just laid out, and abandons the gesture on empty
|
||||||
|
* space (or an unmovable calendar) so tapping a day still opens it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun monthChipDragModifier(
|
||||||
|
week: MonthWeek,
|
||||||
|
moveScope: EventMoveScope?,
|
||||||
|
controller: MonthDragController?,
|
||||||
|
band: Array<LayoutCoordinates?>,
|
||||||
|
rowHeightPx: Float,
|
||||||
|
isRtl: Boolean,
|
||||||
|
): Modifier = rememberDragSurface(
|
||||||
|
enabled = moveScope?.dragEnabled == true && controller != null,
|
||||||
|
key = week.days.first(),
|
||||||
|
onPickUp = { local, pointerInRoot, nodeInRoot, size ->
|
||||||
|
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y
|
||||||
|
val columnPx = size.width / 7f
|
||||||
|
val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y)
|
||||||
|
val lane = (bandY / rowHeightPx).toInt()
|
||||||
|
// The column under the finger on screen, and the day that column shows.
|
||||||
|
val column = (local.x / columnPx).toInt()
|
||||||
|
val dayIndex = if (isRtl) week.days.lastIndex - column else column
|
||||||
|
val event = if (bandY < 0f || columnPx <= 0f) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
|
||||||
|
}
|
||||||
|
if (event == null || moveScope?.allows(event) != true) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
controller?.begin(
|
||||||
|
event = event,
|
||||||
|
grabDate = week.days[dayIndex],
|
||||||
|
pointerInRoot = pointerInRoot,
|
||||||
|
chipInRoot = Offset(
|
||||||
|
x = nodeInRoot.x + column * columnPx,
|
||||||
|
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
||||||
|
),
|
||||||
|
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
||||||
|
)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMove = { controller?.move(it) },
|
||||||
|
onDrop = {
|
||||||
|
controller?.finish()?.let { drop ->
|
||||||
|
// How many columns the finger crossed — grabbing the middle of a
|
||||||
|
// multi-day bar shifts by what it travelled, not to where it landed.
|
||||||
|
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
||||||
|
val took = moveScope?.move(
|
||||||
|
MoveRequest(
|
||||||
|
eventId = drop.event.eventId,
|
||||||
|
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||||
|
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||||
|
target = MoveTarget.ByDays(delta),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Refused, so nothing will land: let the copy go now rather than
|
||||||
|
// hold the chip out for a settle that can never arrive.
|
||||||
|
if (took != true) controller?.release()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel = { controller?.cancel() },
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||||
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||||
@@ -2043,12 +2418,24 @@ private fun MonthBar(
|
|||||||
continuesLeft: Boolean,
|
continuesLeft: Boolean,
|
||||||
continuesRight: Boolean,
|
continuesRight: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
/**
|
||||||
|
* The days this chip covers in its row, for the drag (#68). A drop's own chip
|
||||||
|
* has to ghost until the copy lands on it, and the instance id can't find it:
|
||||||
|
* the provider hands the re-read instance a new one.
|
||||||
|
*/
|
||||||
|
days: List<LocalDate>? = null,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(event.color, dark, soften)
|
val fill = eventFill(event.color, dark, soften)
|
||||||
|
val moveAction = eventMoveAction(event)
|
||||||
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
|
val monthDrag = LocalMonthDrag.current
|
||||||
|
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||||
|
monthDrag?.isSettledChip(event, days) == true
|
||||||
|
val ghost = ghostAlpha(lifted)
|
||||||
val shape = RoundedCornerShape(
|
val shape = RoundedCornerShape(
|
||||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
@@ -2057,9 +2444,13 @@ private fun MonthBar(
|
|||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, shape)
|
.background(fill, shape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics {
|
||||||
|
contentDescription = title
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -2068,6 +2459,7 @@ private fun MonthBar(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2098,7 +2490,7 @@ private fun OverflowDots(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(6.dp)
|
.size(6.dp)
|
||||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||||
.background(eventFill(argb, dark, soften), CircleShape),
|
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val extra = total - dots.size
|
val extra = total - dots.size
|
||||||
|
|||||||
@@ -1,54 +1,31 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.layout.layout
|
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import androidx.compose.ui.graphics.TransformOrigin
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.ui.common.ScaledViewPreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.sampleMonthEvents
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.atTime
|
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toInstant
|
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||||
* chooser.
|
* and onboarding choosers.
|
||||||
*
|
|
||||||
* It renders the *real* grid composables rather than a drawing of them, so the
|
|
||||||
* preview cannot drift from the thing it depicts: change a cell's shape or an
|
|
||||||
* event bar's colour and every preview follows automatically. The trick is
|
|
||||||
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
|
|
||||||
* out at a full phone width and a plausible viewport height, and a
|
|
||||||
* [graphicsLayer] scale shrinks the finished layout into the card.
|
|
||||||
*
|
*
|
||||||
* The month, today's position and the week start are all real; only the events
|
* The month, today's position and the week start are all real; only the events
|
||||||
* are stand-ins, since the settings screen has no business querying the
|
* are stand-ins. See [ScaledViewPreview] for how the real grid composables get
|
||||||
* provider for a thumbnail.
|
* measured at phone size and shrunk into the card.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MonthStylePreview(
|
internal fun MonthStylePreview(
|
||||||
@@ -57,108 +34,64 @@ internal fun MonthStylePreview(
|
|||||||
height: Dp,
|
height: Dp,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
|
||||||
val scale = height.value / VIRTUAL_HEIGHT.value
|
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||||
|
|
||||||
Box(
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
modifier = modifier
|
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||||
.clipToBounds()
|
when (style) {
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
MonthViewStyle.Paged -> MonthGrid(
|
||||||
// A preview is a picture, not a control: swallow touches before the
|
state = sample.month,
|
||||||
// grid's own clickables see them, and give screen readers one label
|
showWeekNumbers = false,
|
||||||
// instead of six weeks of day cells.
|
onOpenDay = {},
|
||||||
.pointerInput(Unit) {
|
onEventClick = {},
|
||||||
awaitPointerEventScope {
|
)
|
||||||
while (true) {
|
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
state = sample.continuous,
|
||||||
.forEach { it.consume() }
|
listState = rememberLazyListState(
|
||||||
}
|
initialFirstVisibleItemIndex = itemIndexForMonth(
|
||||||
}
|
monthIndexOf(YearMonth(today.year, today.month)),
|
||||||
}
|
),
|
||||||
.clearAndSetSemantics { }
|
),
|
||||||
// Measure the grid at a full phone viewport but report the scaled
|
showWeekNumbers = false,
|
||||||
// size, so the node occupies exactly what it draws.
|
onOpenDay = {},
|
||||||
//
|
onEventClick = {},
|
||||||
// Modifier.requiredSize would be the obvious way to force the larger
|
)
|
||||||
// measurement, but it *centres* content that overflows the incoming
|
MonthViewStyle.Dense -> DenseMonthGrid(
|
||||||
// constraints — which pushed the grid to a negative offset and left
|
state = sample.continuous,
|
||||||
// only its bottom-right corner inside the clip.
|
listState = rememberLazyListState(
|
||||||
.layout { measurable, _ ->
|
// Start a week above today's, so the preview shows a stream
|
||||||
val fullWidth = screenWidth.roundToPx()
|
// running past the viewport rather than one beginning at its
|
||||||
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
|
// top edge.
|
||||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
initialFirstVisibleItemIndex = weekIndexOf(today, weekStart) - 1,
|
||||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
),
|
||||||
placeable.place(0, 0)
|
showWeekNumbers = false,
|
||||||
}
|
onOpenDay = {},
|
||||||
}
|
onEventClick = {},
|
||||||
.graphicsLayer {
|
)
|
||||||
scaleX = scale
|
MonthViewStyle.Split -> {
|
||||||
scaleY = scale
|
SplitMonthGrid(
|
||||||
transformOrigin = TransformOrigin(0f, 0f)
|
state = sample.month,
|
||||||
},
|
selected = today,
|
||||||
) {
|
showWeekNumbers = false,
|
||||||
Column(Modifier.fillMaxSize()) {
|
onSelectDay = {},
|
||||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
)
|
||||||
when (style) {
|
SplitDayPane(
|
||||||
MonthViewStyle.Paged -> MonthGrid(
|
date = today,
|
||||||
state = sample.month,
|
today = today,
|
||||||
showWeekNumbers = false,
|
events = sample.month.instancesByDay[today].orEmpty(),
|
||||||
onOpenDay = {},
|
zone = zone,
|
||||||
)
|
onOpenDay = {},
|
||||||
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
onEventClick = {},
|
||||||
state = sample.continuous,
|
onCreateEvent = {},
|
||||||
listState = rememberLazyListState(
|
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||||
initialFirstVisibleItemIndex = itemIndexForMonth(
|
)
|
||||||
monthIndexOf(YearMonth(today.year, today.month)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
showWeekNumbers = false,
|
|
||||||
onOpenDay = {},
|
|
||||||
)
|
|
||||||
MonthViewStyle.Dense -> DenseMonthGrid(
|
|
||||||
state = sample.continuous,
|
|
||||||
listState = rememberLazyListState(
|
|
||||||
// Start a week above today's, so the preview shows a
|
|
||||||
// stream running past the viewport rather than one
|
|
||||||
// beginning at its top edge.
|
|
||||||
initialFirstVisibleItemIndex =
|
|
||||||
weekIndexOf(today, weekStart) - 1,
|
|
||||||
),
|
|
||||||
showWeekNumbers = false,
|
|
||||||
onOpenDay = {},
|
|
||||||
)
|
|
||||||
MonthViewStyle.Split -> {
|
|
||||||
SplitMonthGrid(
|
|
||||||
state = sample.month,
|
|
||||||
selected = today,
|
|
||||||
showWeekNumbers = false,
|
|
||||||
onSelectDay = {},
|
|
||||||
)
|
|
||||||
SplitDayPane(
|
|
||||||
date = today,
|
|
||||||
today = today,
|
|
||||||
events = sample.month.instancesByDay[today].orEmpty(),
|
|
||||||
zone = zone,
|
|
||||||
onOpenDay = {},
|
|
||||||
onEventClick = {},
|
|
||||||
onCreateEvent = {},
|
|
||||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The viewport the preview pretends to be: a phone's width by the height a
|
|
||||||
* calendar view gets under the top bar. Scaling from a fixed height keeps the
|
|
||||||
* three styles comparable — each shows the same slice of screen.
|
|
||||||
*/
|
|
||||||
private val VIRTUAL_HEIGHT = 440.dp
|
|
||||||
private val SPLIT_PANE_HEIGHT = 170.dp
|
private val SPLIT_PANE_HEIGHT = 170.dp
|
||||||
|
|
||||||
private class SampleMonth(
|
private class SampleMonth(
|
||||||
@@ -170,9 +103,6 @@ private class SampleMonth(
|
|||||||
* A month's worth of stand-in events, laid out through the same
|
* A month's worth of stand-in events, laid out through the same
|
||||||
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
* [layoutMonthWeeks] / [clipWeekToMonth] the live views use — so the preview
|
||||||
* exercises the real span, lane and overflow logic rather than approximating it.
|
* exercises the real span, lane and overflow logic rather than approximating it.
|
||||||
*
|
|
||||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
|
||||||
* event, so a token here would misrepresent what the grid actually renders.
|
|
||||||
*/
|
*/
|
||||||
private fun sampleMonthState(
|
private fun sampleMonthState(
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
@@ -181,7 +111,7 @@ private fun sampleMonthState(
|
|||||||
): SampleMonth {
|
): SampleMonth {
|
||||||
val ym = YearMonth(today.year, today.month)
|
val ym = YearMonth(today.year, today.month)
|
||||||
val first = LocalDate(ym.year, ym.month, 1)
|
val first = LocalDate(ym.year, ym.month, 1)
|
||||||
val events = sampleEvents(first, today, zone)
|
val events = sampleMonthEvents(first, today, zone)
|
||||||
|
|
||||||
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||||
val month = MonthUiState.Success(
|
val month = MonthUiState.Success(
|
||||||
@@ -213,72 +143,3 @@ private fun sampleMonthState(
|
|||||||
)
|
)
|
||||||
return SampleMonth(month, continuous)
|
return SampleMonth(month, continuous)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val SAMPLE_COLORS = listOf(
|
|
||||||
0xFF3F7BD4.toInt(),
|
|
||||||
0xFFCE5B4C.toInt(),
|
|
||||||
0xFF4E9A6A.toInt(),
|
|
||||||
0xFF8A63C7.toInt(),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun sampleEvents(
|
|
||||||
firstOfMonth: LocalDate,
|
|
||||||
today: LocalDate,
|
|
||||||
zone: TimeZone,
|
|
||||||
): List<EventInstance> {
|
|
||||||
var id = 0L
|
|
||||||
fun next() = ++id
|
|
||||||
|
|
||||||
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
|
||||||
instanceId = next(),
|
|
||||||
eventId = id,
|
|
||||||
calendarId = 1L,
|
|
||||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
|
||||||
start = day.atTime(hour, 0).toInstant(zone),
|
|
||||||
end = day.atTime(hour + length, 0).toInstant(zone),
|
|
||||||
isAllDay = false,
|
|
||||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
|
||||||
location = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
|
||||||
instanceId = next(),
|
|
||||||
eventId = id,
|
|
||||||
calendarId = 1L,
|
|
||||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
|
||||||
// All-day events sit at UTC midnights with an exclusive end.
|
|
||||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
|
||||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
|
||||||
isAllDay = true,
|
|
||||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
|
||||||
location = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Spread across the month so most weeks carry something, with one multi-day
|
|
||||||
// bar to show a span bridging cells and a busy today for the split pane.
|
|
||||||
return buildList {
|
|
||||||
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
|
||||||
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
|
||||||
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
|
||||||
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
|
||||||
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
|
||||||
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
|
||||||
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
|
||||||
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
|
||||||
// Today, so the split pane's list and the grid's dots both have content.
|
|
||||||
add(timed(today, 9, 1, 0))
|
|
||||||
add(timed(today, 12, 1, 1))
|
|
||||||
add(timed(today, 15, 2, 3))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val SAMPLE_TITLES = listOf(
|
|
||||||
"Standup",
|
|
||||||
"Lunch",
|
|
||||||
"Review",
|
|
||||||
"Gym",
|
|
||||||
"Call",
|
|
||||||
"Workshop",
|
|
||||||
"Dentist",
|
|
||||||
"Trip",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -67,6 +67,23 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
|||||||
return byLane.filterNotNull()
|
return byLane.filterNotNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event drawn in lane [lane] of column [col], or null for an empty slot.
|
||||||
|
* Unlike [laneEvents] the lanes are *not* compacted — this answers "what is under
|
||||||
|
* this point", so an empty lane above a seated bar has to stay empty (#68).
|
||||||
|
*/
|
||||||
|
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||||
|
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||||
|
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
|
||||||
|
val occupied = spans
|
||||||
|
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||||
|
.map { it.lane }
|
||||||
|
.toSet()
|
||||||
|
val free = (0 until laneCap).filter { it !in occupied }
|
||||||
|
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
||||||
|
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step shown only when everything you write to is a device-only calendar
|
||||||
|
* (#163): the events exist nowhere but this phone, so losing it loses them.
|
||||||
|
*
|
||||||
|
* The primary action picks a folder and switches automatic backup on in one go.
|
||||||
|
* Everything it sets is a normal Backup setting, adjustable afterwards.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun BackupStep(
|
||||||
|
onEnable: (Uri) -> Unit,
|
||||||
|
onSkip: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val pickFolder = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri ->
|
||||||
|
// Cancelling the picker decides nothing and leaves the step open.
|
||||||
|
uri?.let(onEnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { IconHero(Icons.Filled.CloudOff) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = { runCatching { pickFolder.launch(null) } },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_backup_enable_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onSkip,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.onboarding_backup_skip_button))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_backup_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_backup_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.FolderOpen,
|
||||||
|
title = stringResource(R.string.onboarding_backup_benefit_folder_title),
|
||||||
|
body = stringResource(R.string.onboarding_backup_benefit_folder_body),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
|
BenefitRow(
|
||||||
|
icon = Icons.Filled.Schedule,
|
||||||
|
title = stringResource(R.string.onboarding_backup_benefit_daily_title),
|
||||||
|
body = stringResource(R.string.onboarding_backup_benefit_daily_body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -16,20 +17,25 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** What, if anything, the brand mark is wearing in its corner. */
|
||||||
|
internal enum class HeroBadge { Denied, Done }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
||||||
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
||||||
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
||||||
* A lock badge overlays the corner when permission has been [denied].
|
* A [badge] overlays the corner where the screen has a verdict to give: blocked,
|
||||||
|
* or finished.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BrandHero(denied: Boolean) {
|
internal fun BrandHero(badge: HeroBadge? = null) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -43,24 +49,56 @@ internal fun BrandHero(denied: Boolean) {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (denied) {
|
if (badge != null) {
|
||||||
// A small lock badge sits over the corner to signal "blocked".
|
val container = when (badge) {
|
||||||
|
HeroBadge.Denied -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
HeroBadge.Done -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
}
|
||||||
|
val onContainer = when (badge) {
|
||||||
|
HeroBadge.Denied -> MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
HeroBadge.Done -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.offset(x = 10.dp, y = 10.dp)
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
.size(44.dp)
|
.size(44.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.errorContainer),
|
.background(container),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Lock,
|
imageVector = when (badge) {
|
||||||
|
HeroBadge.Denied -> Icons.Filled.Lock
|
||||||
|
HeroBadge.Done -> Icons.Filled.Check
|
||||||
|
},
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
tint = onContainer,
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single [icon] in the brand squircle — the same silhouette as [BrandHero],
|
||||||
|
* for the wizard steps that stand for a feature rather than for the app itself.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun IconHero(icon: ImageVector) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The wizard's closing screen (#163): the flow ends on a full screen saying so
|
||||||
|
* rather than dropping into the calendar mid-gesture, and the brand mark wears
|
||||||
|
* the check that the last step earned.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun DoneStep(
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { BrandHero(HeroBadge.Done) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onFinished,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_done_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_done_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_done_body),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.animation.ContentTransform
|
||||||
|
import androidx.compose.animation.SizeTransform
|
||||||
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The M3 Expressive spatial spring the wizard's steps travel on. Read in
|
||||||
|
* composable scope so it can be captured by `AnimatedContent`'s non-composable
|
||||||
|
* transitionSpec lambda.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun rememberOnboardingSlideSpec(): FiniteAnimationSpec<IntOffset> =
|
||||||
|
MaterialTheme.motionScheme.defaultSpatialSpec()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moving between wizard steps (#163): the steps sit side by side and the whole
|
||||||
|
* width swipes across, position on a spring so the arriving step settles with a
|
||||||
|
* little give rather than stopping dead.
|
||||||
|
*
|
||||||
|
* No cross-fade, unlike the calendar's shared-axis paging: two steps have
|
||||||
|
* nothing in common, so fading one through the other smears. Both are opaque and
|
||||||
|
* travel in lockstep, showing one screen pushing the other off.
|
||||||
|
*
|
||||||
|
* @param slideDir +1 = forward (incoming from the right), -1 = back.
|
||||||
|
* @param spec spatial spec, typically [rememberOnboardingSlideSpec].
|
||||||
|
* @param fadeSpec effects spec used for the whole transition under reduced
|
||||||
|
* motion.
|
||||||
|
* @param reduceMotion when true, drop the movement and cross-fade alone.
|
||||||
|
*/
|
||||||
|
internal fun onboardingSlideTransition(
|
||||||
|
slideDir: Int,
|
||||||
|
spec: FiniteAnimationSpec<IntOffset>,
|
||||||
|
fadeSpec: FiniteAnimationSpec<Float>,
|
||||||
|
reduceMotion: Boolean,
|
||||||
|
): ContentTransform {
|
||||||
|
if (reduceMotion) {
|
||||||
|
return fadeIn(fadeSpec).togetherWith(fadeOut(fadeSpec))
|
||||||
|
}
|
||||||
|
val dir = if (slideDir == 0) 1 else slideDir
|
||||||
|
return ContentTransform(
|
||||||
|
targetContentEnter = slideInHorizontally(spec) { w -> dir * w },
|
||||||
|
initialContentExit = slideOutHorizontally(spec) { w -> -dir * w },
|
||||||
|
// Clipping to the animating container would shear both steps against the
|
||||||
|
// viewport edge as they pass; there is no size change here to contain.
|
||||||
|
sizeTransform = SizeTransform(clip = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
/** One screen of the first-launch wizard (#163), in the order they are shown. */
|
||||||
|
enum class OnboardingStep {
|
||||||
|
/** Required: nothing works without the calendar grant. */
|
||||||
|
Permission,
|
||||||
|
|
||||||
|
/** Whether Calendula delivers reminder notifications itself. */
|
||||||
|
Reminders,
|
||||||
|
|
||||||
|
/** Only when nothing you write to is synced anywhere — offers a backup. */
|
||||||
|
Backup,
|
||||||
|
|
||||||
|
/** Which view the app opens on, chosen from a live preview. */
|
||||||
|
View,
|
||||||
|
|
||||||
|
/** How the Month view lays itself out, chosen from a live preview. */
|
||||||
|
MonthStyle,
|
||||||
|
|
||||||
|
/** Only on an install that has calendars the device is hiding (#75). */
|
||||||
|
Visibility,
|
||||||
|
|
||||||
|
/** The closing screen: everything is answered and the app is ready. */
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which steps this install owes and where it has got to. [steps] is the whole
|
||||||
|
* flow — completed steps included — so the counter doesn't renumber as steps
|
||||||
|
* are finished; [current] is null once nothing is left and the app opens.
|
||||||
|
*/
|
||||||
|
data class OnboardingPlan(
|
||||||
|
val steps: List<OnboardingStep>,
|
||||||
|
val current: OnboardingStep?,
|
||||||
|
) {
|
||||||
|
/** 1-based position of [current] in [steps], or 0 when the flow is done. */
|
||||||
|
val index: Int get() = steps.indexOf(current) + 1
|
||||||
|
|
||||||
|
val total: Int get() = steps.size
|
||||||
|
|
||||||
|
/** A one-step flow is not a wizard: there is nothing to count down. */
|
||||||
|
val showsProgress: Boolean get() = total > 1
|
||||||
|
|
||||||
|
/** The step before [current], or null at the start (and once finished). */
|
||||||
|
val previous: OnboardingStep? get() = steps.getOrNull(index - 2).takeIf { current != null }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the wizard can step back. Everything is re-askable except the
|
||||||
|
* calendar grant, which belongs to the system — once given there is nothing
|
||||||
|
* for a back press to return to.
|
||||||
|
*/
|
||||||
|
val canGoBack: Boolean get() = previous != null && previous != OnboardingStep.Permission
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Work out the flow from what is stored.
|
||||||
|
*
|
||||||
|
* The optional steps belong to installs still working through their first run.
|
||||||
|
* Before the grant that can only be guessed, so one that has not answered the
|
||||||
|
* reminder step either counts as fresh — the same condition
|
||||||
|
* [de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs.armOnboardingWizard]
|
||||||
|
* commits to storage a moment later, and clears again on the closing screen so
|
||||||
|
* a later re-grant is only the permission step.
|
||||||
|
*
|
||||||
|
* [backupApplies] is null until the calendar list can be read, i.e. for the
|
||||||
|
* whole permission step; the backup step is assumed to apply until proven
|
||||||
|
* otherwise, so the flow can only ever get shorter — never sprout a step the
|
||||||
|
* counter had not accounted for.
|
||||||
|
*
|
||||||
|
* The month-style step is *not* conditional on Month being the chosen view:
|
||||||
|
* Month is reachable from the drawer whatever opens first, and a step that came
|
||||||
|
* and went as the view is picked would move the counter under the user on the
|
||||||
|
* step right before it.
|
||||||
|
*
|
||||||
|
* [visibilityArmed] carries the one-time notice that the device is hiding some
|
||||||
|
* calendars (#75) — the only optional step an *existing* install can be given,
|
||||||
|
* which is why it is not gated on `fresh`, and on its own it earns no closing
|
||||||
|
* screen.
|
||||||
|
*/
|
||||||
|
fun onboardingPlan(
|
||||||
|
hasPermission: Boolean,
|
||||||
|
remindersDone: Boolean,
|
||||||
|
wizardArmed: Boolean,
|
||||||
|
backupDone: Boolean,
|
||||||
|
viewDone: Boolean,
|
||||||
|
monthStyleDone: Boolean,
|
||||||
|
backupApplies: Boolean?,
|
||||||
|
visibilityArmed: Boolean = false,
|
||||||
|
visibilityDone: Boolean = false,
|
||||||
|
doneShown: Boolean = false,
|
||||||
|
): OnboardingPlan {
|
||||||
|
val fresh = wizardArmed || (!hasPermission && !remindersDone)
|
||||||
|
val steps = buildList {
|
||||||
|
if (!hasPermission || fresh) add(OnboardingStep.Permission)
|
||||||
|
if (!remindersDone || fresh) add(OnboardingStep.Reminders)
|
||||||
|
if (fresh && backupApplies != false) add(OnboardingStep.Backup)
|
||||||
|
if (fresh) {
|
||||||
|
add(OnboardingStep.View)
|
||||||
|
add(OnboardingStep.MonthStyle)
|
||||||
|
}
|
||||||
|
if (visibilityArmed) add(OnboardingStep.Visibility)
|
||||||
|
if (fresh) add(OnboardingStep.Done)
|
||||||
|
}
|
||||||
|
val current = steps.firstOrNull { step ->
|
||||||
|
when (step) {
|
||||||
|
OnboardingStep.Permission -> !hasPermission
|
||||||
|
OnboardingStep.Reminders -> !remindersDone
|
||||||
|
OnboardingStep.Backup -> !backupDone
|
||||||
|
OnboardingStep.View -> !viewDone
|
||||||
|
OnboardingStep.MonthStyle -> !monthStyleDone
|
||||||
|
OnboardingStep.Visibility -> !visibilityDone
|
||||||
|
OnboardingStep.Done -> !doneShown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OnboardingPlan(steps, current)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingProgress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the wizard's current [step] (#163). Each step commits its own answer
|
||||||
|
* through [viewModel], which then hands the host the next one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OnboardingSteps(
|
||||||
|
step: OnboardingStep,
|
||||||
|
plan: OnboardingPlan,
|
||||||
|
viewModel: OnboardingViewModel,
|
||||||
|
onPermissionGranted: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
// Collected for every step, not just the two that draw a preview: starting
|
||||||
|
// it when the picker composes would leave that step blank as it slides in.
|
||||||
|
val choice by viewModel.viewChoice.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Coerced because the outgoing half of a transition may be a step the live
|
||||||
|
// plan has since dropped — the backup step goes once the calendars say it
|
||||||
|
// does not apply — and a "Step 0 of 3" flash is worse than a stale number.
|
||||||
|
val index = (plan.steps.indexOf(step) + 1).coerceAtLeast(1)
|
||||||
|
val progress: (@Composable () -> Unit)? = if (!plan.showsProgress) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
OnboardingProgress(
|
||||||
|
step = index,
|
||||||
|
total = plan.total,
|
||||||
|
label = stringResource(R.string.onboarding_step_counter, index, plan.total),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The system back gesture does the same thing as the button, and stays off
|
||||||
|
// on the first step so back still leaves the app.
|
||||||
|
BackHandler(enabled = plan.canGoBack) { viewModel.goBack() }
|
||||||
|
val navigationIcon: (@Composable () -> Unit)? = if (!plan.canGoBack) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
IconButton(onClick = viewModel::goBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.onboarding_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (step) {
|
||||||
|
OnboardingStep.Permission -> PermissionScreen(
|
||||||
|
onGranted = onPermissionGranted,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
)
|
||||||
|
OnboardingStep.Reminders -> ReminderStep(
|
||||||
|
onFinished = viewModel::finishReminders,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
OnboardingStep.Backup -> BackupStep(
|
||||||
|
onEnable = viewModel::enableAutoBackup,
|
||||||
|
onSkip = viewModel::skipBackup,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
// The choice is null only until DataStore's first emission; rendering
|
||||||
|
// nothing for that frame beats a preview built on the wrong defaults.
|
||||||
|
OnboardingStep.View -> choice?.let {
|
||||||
|
ViewStep(
|
||||||
|
choice = it,
|
||||||
|
onSelect = viewModel::setDefaultView,
|
||||||
|
onFinished = viewModel::finishView,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OnboardingStep.MonthStyle -> choice?.let {
|
||||||
|
MonthStyleStep(
|
||||||
|
choice = it,
|
||||||
|
onSelect = viewModel::setMonthViewStyle,
|
||||||
|
onFinished = viewModel::finishMonthStyle,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OnboardingStep.Visibility -> VisibilityStep(
|
||||||
|
onFinished = viewModel::finishVisibility,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
OnboardingStep.Done -> DoneStep(
|
||||||
|
onFinished = viewModel::finishOnboarding,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||||
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
||||||
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the first-launch wizard (#163): decides which steps this install owes
|
||||||
|
* (see [onboardingPlan]) and commits each answer.
|
||||||
|
*
|
||||||
|
* [plan] is null until the first stored emission, so the host renders nothing
|
||||||
|
* for that frame rather than flashing a step the user has already answered.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class OnboardingViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
private val calendarPrefs: CalendarPrefs,
|
||||||
|
private val repository: CalendarRepository,
|
||||||
|
private val scanner: ReminderScanner,
|
||||||
|
private val visibilityReconciler: CalendarVisibilityReconciler,
|
||||||
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Null until the host reports it — assuming either way would flash a screen. */
|
||||||
|
private val permissionReported = MutableStateFlow<Boolean?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grant as the rest of the flow sees it: published only once the wizard
|
||||||
|
* has been armed for it, since arming is a stored write and the frames
|
||||||
|
* before it lands would collapse the plan to its one-step form.
|
||||||
|
*/
|
||||||
|
private val hasPermission: StateFlow<Boolean?> = permissionReported
|
||||||
|
.mapLatest { granted ->
|
||||||
|
if (granted == true) armWizardIfFresh()
|
||||||
|
granted
|
||||||
|
}
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the backup step applies: something of yours is worth exporting and
|
||||||
|
* nothing you write to is synced anywhere. Null while the calendar list
|
||||||
|
* cannot be read: every frame before the grant, and any read that fails —
|
||||||
|
* an empty list is also how a null cursor arrives, and guessing false there
|
||||||
|
* would drop the step for the whole session.
|
||||||
|
*/
|
||||||
|
private val backupApplies: Flow<Boolean?> = hasPermission.flatMapLatest { granted ->
|
||||||
|
if (granted != true) {
|
||||||
|
flowOf(null)
|
||||||
|
} else {
|
||||||
|
repository.calendars()
|
||||||
|
.map { calendars -> if (calendars.isEmpty()) null else calendars.backupApplies() }
|
||||||
|
.catch { emit(null) }
|
||||||
|
.flowOn(io)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val flags: Flow<OnboardingFlags> = combine(
|
||||||
|
prefs.reminderOnboardingDone,
|
||||||
|
prefs.onboardingWizardArmed,
|
||||||
|
prefs.onboardingBackupDone,
|
||||||
|
prefs.onboardingViewDone,
|
||||||
|
prefs.onboardingMonthStyleDone,
|
||||||
|
::OnboardingFlags,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The closing steps' own flags, and whether the notice applies at all. */
|
||||||
|
private val closingFlags: Flow<ClosingFlags> = combine(
|
||||||
|
calendarPrefs.visibilityNoticePending.map { it == true },
|
||||||
|
prefs.onboardingVisibilityDone,
|
||||||
|
prefs.onboardingDoneShown,
|
||||||
|
::ClosingFlags,
|
||||||
|
)
|
||||||
|
|
||||||
|
val plan: StateFlow<OnboardingPlan?> =
|
||||||
|
combine(hasPermission, flags, closingFlags, backupApplies) { granted, stored, closing, backup ->
|
||||||
|
granted?.let {
|
||||||
|
onboardingPlan(
|
||||||
|
hasPermission = it,
|
||||||
|
remindersDone = stored.remindersDone,
|
||||||
|
wizardArmed = stored.wizardArmed,
|
||||||
|
backupDone = stored.backupDone,
|
||||||
|
viewDone = stored.viewDone,
|
||||||
|
monthStyleDone = stored.monthStyleDone,
|
||||||
|
backupApplies = backup,
|
||||||
|
visibilityArmed = closing.visibilityArmed,
|
||||||
|
visibilityDone = closing.visibilityDone,
|
||||||
|
doneShown = closing.doneShown,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The current view preferences, for the view step's initial selection. */
|
||||||
|
val viewChoice: StateFlow<ViewChoice?> = combine(
|
||||||
|
prefs.defaultView,
|
||||||
|
prefs.monthViewStyle,
|
||||||
|
prefs.weekStart,
|
||||||
|
::ViewChoice,
|
||||||
|
).stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setHasPermission(granted: Boolean) {
|
||||||
|
permissionReported.value = granted
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the wizard for an install that has yet to answer the reminder step,
|
||||||
|
* whatever route the grant took. Gating this on the in-app grant would
|
||||||
|
* strand anyone who granted on Android's own settings screen and came back
|
||||||
|
* to a restarted process, leaving them the reminder step alone.
|
||||||
|
*/
|
||||||
|
private suspend fun armWizardIfFresh() {
|
||||||
|
if (!prefs.reminderOnboardingDone.first()) prefs.armOnboardingWizard()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the in-app grant. The launch scan ran before it and bailed out
|
||||||
|
* without arming anything, so the first alarm would otherwise wait on the
|
||||||
|
* daily worker.
|
||||||
|
*/
|
||||||
|
fun onPermissionGranted() {
|
||||||
|
scanner.scanInBackground()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the reminder step, recording whether notifications stay on. */
|
||||||
|
fun finishReminders(remindersEnabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setRemindersEnabled(remindersEnabled)
|
||||||
|
prefs.setReminderOnboardingDone()
|
||||||
|
// Nothing else re-arms the scan: turning reminders off cancels the
|
||||||
|
// alarm (#75).
|
||||||
|
scanner.scan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-ask the step before the current one, by clearing its answer. The plan
|
||||||
|
* derives the position from those answers, so un-setting one *is* the back
|
||||||
|
* navigation. The calendar grant belongs to the system and cannot be
|
||||||
|
* returned to, which [OnboardingPlan.canGoBack] already refuses.
|
||||||
|
*/
|
||||||
|
fun goBack() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (plan.value?.previous) {
|
||||||
|
OnboardingStep.Reminders -> prefs.setReminderOnboardingDone(false)
|
||||||
|
OnboardingStep.Backup -> prefs.setOnboardingBackupDone(false)
|
||||||
|
OnboardingStep.View -> prefs.setOnboardingViewDone(false)
|
||||||
|
OnboardingStep.MonthStyle -> prefs.setOnboardingMonthStyleDone(false)
|
||||||
|
OnboardingStep.Visibility -> prefs.setOnboardingVisibilityDone(false)
|
||||||
|
OnboardingStep.Done -> prefs.setOnboardingDoneShown(false)
|
||||||
|
OnboardingStep.Permission, null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the backup step without setting anything up. */
|
||||||
|
fun skipBackup() {
|
||||||
|
viewModelScope.launch { prefs.setOnboardingBackupDone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn automatic backup on, writing to the folder the user just picked
|
||||||
|
* (taking a durable write grant so background runs can keep writing), and
|
||||||
|
* close the step.
|
||||||
|
*/
|
||||||
|
fun enableAutoBackup(folder: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
folder,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
prefs.setAutoBackupFolderUri(folder.toString())
|
||||||
|
prefs.setAutoBackupEnabled(true)
|
||||||
|
BackupScheduler.apply(
|
||||||
|
context = context,
|
||||||
|
enabled = true,
|
||||||
|
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||||
|
hasFolder = true,
|
||||||
|
)
|
||||||
|
// Immediate feedback: the first backup lands while onboarding runs.
|
||||||
|
BackupScheduler.runNow(context)
|
||||||
|
prefs.setOnboardingBackupDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultView(view: CalendarView) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMonthViewStyle(style: MonthViewStyle) {
|
||||||
|
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close a picker step — the choice itself is saved as it is made. */
|
||||||
|
fun finishView() {
|
||||||
|
viewModelScope.launch { prefs.setOnboardingViewDone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishMonthStyle() {
|
||||||
|
viewModelScope.launch { prefs.setOnboardingMonthStyleDone() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acknowledge the calendar-visibility notice (#75). */
|
||||||
|
fun finishVisibility() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setOnboardingVisibilityDone()
|
||||||
|
// Retiring the notice drops its step, so it waits until nothing
|
||||||
|
// follows — the count must not fall on the way to a later screen.
|
||||||
|
if (plan.value?.steps?.last() == OnboardingStep.Visibility) retireVisibilityNotice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the wizard from its last screen. */
|
||||||
|
fun finishOnboarding() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.finishOnboardingWizard()
|
||||||
|
retireVisibilityNotice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put the one-time visibility notice away for good — nothing else clears it
|
||||||
|
* now that the wizard owns it, and left standing it would keep its step in
|
||||||
|
* every later plan.
|
||||||
|
*/
|
||||||
|
private suspend fun retireVisibilityNotice() {
|
||||||
|
if (calendarPrefs.visibilityNoticePending.first() == true) {
|
||||||
|
calendarPrefs.setVisibilityNoticePending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold the retired app-local calendar visibility into the device's own, and
|
||||||
|
* arm the notice if this install has anything to announce. Runs whenever the
|
||||||
|
* app comes up holding the permission, however it was granted — one taken on
|
||||||
|
* Android's app-settings screen never reaches the permission step's callback.
|
||||||
|
*/
|
||||||
|
fun reconcileCalendarVisibility() {
|
||||||
|
viewModelScope.launch { visibilityReconciler.run() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored answers the plan is derived from. */
|
||||||
|
private data class OnboardingFlags(
|
||||||
|
val remindersDone: Boolean,
|
||||||
|
val wizardArmed: Boolean,
|
||||||
|
val backupDone: Boolean,
|
||||||
|
val viewDone: Boolean,
|
||||||
|
val monthStyleDone: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The tail of the flow: the visibility notice and the closing screen. */
|
||||||
|
private data class ClosingFlags(
|
||||||
|
val visibilityArmed: Boolean,
|
||||||
|
val visibilityDone: Boolean,
|
||||||
|
val doneShown: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What the view step starts from, and renders its previews with. */
|
||||||
|
data class ViewChoice(
|
||||||
|
val defaultView: CalendarView,
|
||||||
|
val monthViewStyle: MonthViewStyle,
|
||||||
|
val weekStart: WeekStartPref,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when this device holds events that live nowhere else: at least one local
|
||||||
|
* calendar worth exporting, and no writable synced calendar to carry them.
|
||||||
|
* A read-only subscription is not a backup, so it does not count.
|
||||||
|
*/
|
||||||
|
private fun List<CalendarSource>.backupApplies(): Boolean {
|
||||||
|
val exportable = any { it.isLocal && it.canModifyContents && !it.isManaged }
|
||||||
|
val syncedTarget = any { !it.isLocal && it.canModifyContents }
|
||||||
|
return exportable && !syncedTarget
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
@@ -10,19 +10,15 @@ import android.net.Uri
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -32,13 +28,11 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
@@ -52,6 +46,7 @@ private val CALENDAR_PERMISSIONS = arrayOf(
|
|||||||
fun PermissionScreen(
|
fun PermissionScreen(
|
||||||
onGranted: () -> Unit,
|
onGranted: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
viewModel: PermissionViewModel = hiltViewModel(),
|
viewModel: PermissionViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
@@ -76,6 +71,7 @@ fun PermissionScreen(
|
|||||||
when (state) {
|
when (state) {
|
||||||
is PermissionUiState.Rationale -> RationaleContent(
|
is PermissionUiState.Rationale -> RationaleContent(
|
||||||
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
onRequest = { launcher.launch(CALENDAR_PERMISSIONS) },
|
||||||
|
progress = progress,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is PermissionUiState.Denied -> DeniedContent(
|
is PermissionUiState.Denied -> DeniedContent(
|
||||||
@@ -83,6 +79,7 @@ fun PermissionScreen(
|
|||||||
viewModel.onRetry()
|
viewModel.onRetry()
|
||||||
launcher.launch(CALENDAR_PERMISSIONS)
|
launcher.launch(CALENDAR_PERMISSIONS)
|
||||||
},
|
},
|
||||||
|
progress = progress,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
is PermissionUiState.Granted -> {
|
is PermissionUiState.Granted -> {
|
||||||
@@ -94,11 +91,14 @@ fun PermissionScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RationaleContent(
|
private fun RationaleContent(
|
||||||
onRequest: () -> Unit,
|
onRequest: () -> Unit,
|
||||||
|
progress: (@Composable () -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
OnboardingScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BrandHero(denied = false) },
|
progress = progress,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { BrandHero() },
|
||||||
actions = {
|
actions = {
|
||||||
Button(
|
Button(
|
||||||
onClick = onRequest,
|
onClick = onRequest,
|
||||||
@@ -116,16 +116,8 @@ private fun RationaleContent(
|
|||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PrivacyFootnote()
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_name).uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
letterSpacing = 2.sp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.permission_rationale_title),
|
text = stringResource(R.string.permission_rationale_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -139,14 +131,8 @@ private fun RationaleContent(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
|
||||||
BenefitRow(
|
|
||||||
icon = Icons.Filled.Lock,
|
|
||||||
title = stringResource(R.string.permission_benefit_private_title),
|
|
||||||
body = stringResource(R.string.permission_benefit_private_body),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.CalendarMonth,
|
icon = Icons.Filled.CalendarMonth,
|
||||||
title = stringResource(R.string.permission_benefit_sync_title),
|
title = stringResource(R.string.permission_benefit_sync_title),
|
||||||
@@ -154,9 +140,9 @@ private fun RationaleContent(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
Spacer(Modifier.height(OnboardingSpace.sm))
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.VisibilityOff,
|
icon = Icons.Filled.Lock,
|
||||||
title = stringResource(R.string.permission_benefit_privacy_title),
|
title = stringResource(R.string.permission_benefit_private_title),
|
||||||
body = stringResource(R.string.permission_benefit_privacy_body),
|
body = stringResource(R.string.permission_benefit_private_body),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,12 +150,14 @@ private fun RationaleContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DeniedContent(
|
private fun DeniedContent(
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
|
progress: (@Composable () -> Unit)?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
OnboardingScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BrandHero(denied = true) },
|
progress = progress,
|
||||||
|
hero = { BrandHero(HeroBadge.Denied) },
|
||||||
actions = {
|
actions = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -208,25 +196,3 @@ private fun DeniedContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PrivacyFootnote() {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.permission_privacy_footnote),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
sealed interface PermissionUiState {
|
sealed interface PermissionUiState {
|
||||||
data object Rationale : PermissionUiState
|
data object Rationale : PermissionUiState
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
@@ -8,34 +8,25 @@ import android.Manifest
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.NotificationsActive
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Tune
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time onboarding step after the calendar grant (v1.4): explains that
|
* Wizard step after the calendar grant (v1.4): explains that
|
||||||
* Calendula delivers reminder notifications itself, warns about duplicates
|
* Calendula delivers reminder notifications itself, warns about duplicates
|
||||||
* when a second calendar app has notifications on, and requests
|
* when a second calendar app has notifications on, and requests
|
||||||
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
* `POST_NOTIFICATIONS` (a system dialog on API 33+ only; minSdk is 29).
|
||||||
@@ -45,9 +36,11 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
* the Settings toggle re-requests it. "Not now" turns the in-app toggle off.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderOnboardingScreen(
|
fun ReminderStep(
|
||||||
onFinished: (remindersEnabled: Boolean) -> Unit,
|
onFinished: (remindersEnabled: Boolean) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
@@ -55,7 +48,10 @@ fun ReminderOnboardingScreen(
|
|||||||
|
|
||||||
OnboardingScaffold(
|
OnboardingScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
hero = { BellHero() },
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { IconHero(Icons.Filled.NotificationsActive) },
|
||||||
actions = {
|
actions = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -80,13 +76,6 @@ fun ReminderOnboardingScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_name).uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
letterSpacing = 2.sp,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xs))
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.reminder_onboarding_title),
|
text = stringResource(R.string.reminder_onboarding_title),
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -100,7 +89,7 @@ fun ReminderOnboardingScreen(
|
|||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
|
||||||
BenefitRow(
|
BenefitRow(
|
||||||
icon = Icons.Filled.NotificationsActive,
|
icon = Icons.Filled.NotificationsActive,
|
||||||
@@ -113,30 +102,5 @@ fun ReminderOnboardingScreen(
|
|||||||
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
title = stringResource(R.string.reminder_benefit_duplicates_title),
|
||||||
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
body = stringResource(R.string.reminder_benefit_duplicates_body),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(OnboardingSpace.sm))
|
|
||||||
BenefitRow(
|
|
||||||
icon = Icons.Filled.Tune,
|
|
||||||
title = stringResource(R.string.reminder_benefit_reversible_title),
|
|
||||||
body = stringResource(R.string.reminder_benefit_reversible_body),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A bell in the brand squircle — same silhouette as the permission hero. */
|
|
||||||
@Composable
|
|
||||||
private fun BellHero() {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
.clip(RoundedCornerShape(34.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.NotificationsActive,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.size(56.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaViewPreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.day.DayViewPreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||||
|
import de.jeanlucmakiola.calendula.ui.week.WeekViewPreview
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step: which view the app opens on (#163). Built like the Settings
|
||||||
|
* picker it mirrors — a live preview over connected grouped rows. Selecting
|
||||||
|
* applies immediately and leaves the step open; the preview is the confirmation.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ViewStep(
|
||||||
|
choice: ViewChoice,
|
||||||
|
onSelect: (CalendarView) -> Unit,
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
||||||
|
|
||||||
|
StepPickerScaffold(
|
||||||
|
title = stringResource(R.string.onboarding_view_title),
|
||||||
|
onFinished = onFinished,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
) {
|
||||||
|
StepPreview(selected = choice.defaultView) { view ->
|
||||||
|
when (view) {
|
||||||
|
CalendarView.Month -> MonthStylePreview(
|
||||||
|
style = choice.monthViewStyle,
|
||||||
|
weekStart = weekStart,
|
||||||
|
height = PREVIEW_HEIGHT,
|
||||||
|
)
|
||||||
|
CalendarView.Week -> WeekViewPreview(weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||||
|
CalendarView.Day -> DayViewPreview(height = PREVIEW_HEIGHT)
|
||||||
|
CalendarView.Agenda -> AgendaViewPreview(height = PREVIEW_HEIGHT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||||
|
val isSelected = view == choice.defaultView
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(view.labelRes),
|
||||||
|
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||||
|
selected = isSelected,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = view.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { onSelect(view) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step: how the Month view lays itself out (#163). The same shape as
|
||||||
|
* [ViewStep] and as the Settings chooser this duplicates, down to the selected
|
||||||
|
* style's own blurb sitting under its preview rather than on every row.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun MonthStyleStep(
|
||||||
|
choice: ViewChoice,
|
||||||
|
onSelect: (MonthViewStyle) -> Unit,
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val weekStart = choice.weekStart.resolveFirstDay(currentLocale())
|
||||||
|
val options = MonthViewStyle.entries
|
||||||
|
|
||||||
|
StepPickerScaffold(
|
||||||
|
title = stringResource(R.string.onboarding_month_style_title),
|
||||||
|
onFinished = onFinished,
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
) {
|
||||||
|
StepPreview(selected = choice.monthViewStyle) { style ->
|
||||||
|
MonthStylePreview(style = style, weekStart = weekStart, height = PREVIEW_HEIGHT)
|
||||||
|
}
|
||||||
|
PickerDescription(stringResource(choice.monthViewStyle.descriptionRes))
|
||||||
|
options.forEachIndexed { index, style ->
|
||||||
|
val isSelected = style == choice.monthViewStyle
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(style.labelRes),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { onSelect(style) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shell both picker steps share: the wizard chrome around a title and a
|
||||||
|
* full-bleed column, since grouped rows carry their own inset.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StepPickerScaffold(
|
||||||
|
title: String,
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.xs,
|
||||||
|
contentPadding = 0.dp,
|
||||||
|
// The preview gets the room it needs; Continue scrolls in under the
|
||||||
|
// options rather than pinning to the bottom.
|
||||||
|
scrollingActions = true,
|
||||||
|
hero = {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = GroupedListInset),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onFinished,
|
||||||
|
// Full-bleed step: the button takes the grouped rows' own inset
|
||||||
|
// and ends up as wide as the options above it.
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = GroupedListInset)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_view_continue_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
body = body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live preview, framed like the picker's. The wider gap below keeps the rows
|
||||||
|
* reading as a group of their own rather than as the preview's caption.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun <T> StepPreview(selected: T, content: @Composable (T) -> Unit) {
|
||||||
|
ViewPreviewFrame(
|
||||||
|
selected = selected,
|
||||||
|
label = "step-preview",
|
||||||
|
modifier = Modifier.padding(top = OnboardingSpace.xs, bottom = OnboardingSpace.md),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same size the Settings chooser gives it. */
|
||||||
|
private val PREVIEW_HEIGHT: Dp = 280.dp
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wizard step for the one-time notice that Calendula now follows the device's
|
||||||
|
* own per-calendar visibility and some of yours are switched off (#75, #163).
|
||||||
|
*
|
||||||
|
* Nothing to decide here — the app does not switch those calendars back on,
|
||||||
|
* since that would un-hide them everywhere else too.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun VisibilityStep(
|
||||||
|
onFinished: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (@Composable () -> Unit)? = null,
|
||||||
|
navigationIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
OnboardingScaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
progress = progress,
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
topSpacing = OnboardingSpace.lg,
|
||||||
|
hero = { IconHero(Icons.Filled.VisibilityOff) },
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
|
onClick = onFinished,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.onboarding_visibility_button),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_visibility_notice_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_visibility_notice_message),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gates the one-time reminder onboarding step (v1.4) shown after the calendar
|
|
||||||
* grant. [onboardingDone] is null until DataStore's first emission so the
|
|
||||||
* step neither flashes for users who completed it nor gets skipped.
|
|
||||||
*/
|
|
||||||
@HiltViewModel
|
|
||||||
class ReminderOnboardingViewModel @Inject constructor(
|
|
||||||
private val prefs: SettingsPrefs,
|
|
||||||
private val scanner: ReminderScanner,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
|
|
||||||
.map { done -> done as Boolean? }
|
|
||||||
.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
|
||||||
initialValue = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Close the step, recording whether reminder notifications stay on. */
|
|
||||||
fun finish(remindersEnabled: Boolean) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
prefs.setRemindersEnabled(remindersEnabled)
|
|
||||||
prefs.setReminderOnboardingDone()
|
|
||||||
// Nothing else re-arms the scan: turning reminders off cancels the
|
|
||||||
// alarm (#75).
|
|
||||||
scanner.scan()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-scan after the calendar permission is granted. The launch scan runs
|
|
||||||
* before the grant and bails out without arming anything, so without this
|
|
||||||
* the first alarm waits on the daily worker.
|
|
||||||
*/
|
|
||||||
fun rearmAfterGrant() {
|
|
||||||
scanner.scanInBackground()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +1,109 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.search
|
package de.jeanlucmakiola.calendula.ui.search
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
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
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
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.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.SearchOff
|
import androidx.compose.material.icons.filled.SearchOff
|
||||||
|
import androidx.compose.material.icons.filled.SelectAll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.MatchSpan
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchMonth
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.declinedTitle
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChip
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||||
|
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
@@ -68,11 +111,13 @@ import java.time.ZoneId
|
|||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
/** How long the delete confirmation chip stays up, matching a short snackbar. */
|
||||||
|
private const val CHIP_MILLIS = 4_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-text event search (top-bar entry). Type a query → matching events
|
* Full-text event search over title / location / description: tap a result to
|
||||||
* (title / location / description) across the whole calendar, newest-relevant
|
* open its detail, long-press to select several and delete them at once (#80).
|
||||||
* first; tap a result to open its detail. A full-screen overlay hosted by
|
* A full-screen overlay hosted by [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
||||||
* [de.jeanlucmakiola.calendula.ui.CalendarHost].
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -84,63 +129,79 @@ fun SearchScreen(
|
|||||||
) {
|
) {
|
||||||
val query by viewModel.query.collectAsStateWithLifecycle()
|
val query by viewModel.query.collectAsStateWithLifecycle()
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val selection by viewModel.selection.collectAsStateWithLifecycle()
|
||||||
|
val deleteState by viewModel.deleteState.collectAsStateWithLifecycle()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val inSelection = selection.isNotEmpty()
|
||||||
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Each fresh open starts blank and straight into typing. The ViewModel is
|
// The ViewModel is activity-scoped, so a re-enter is what resets the last
|
||||||
// activity-scoped so it outlives the overlay; clearing on (re)enter is what
|
// search, its selection and any delete receipt still up. Peeking a result
|
||||||
// resets a previous search. Peeking a result doesn't re-run this (the screen
|
// doesn't re-run this — the screen stays composed under the detail.
|
||||||
// stays composed under the detail), so backing out keeps the query.
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.setQuery("")
|
viewModel.setQuery("")
|
||||||
|
viewModel.clearSelection()
|
||||||
|
viewModel.consumeDeleteResult()
|
||||||
|
}
|
||||||
|
// The field only exists out of selection mode, so a recreation that lands
|
||||||
|
// mid-selection has to wait for the clear above before it can be focused.
|
||||||
|
var focusPending by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(inSelection) {
|
||||||
|
if (inSelection || !focusPending) return@LaunchedEffect
|
||||||
|
focusPending = false
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
keyboard?.show()
|
keyboard?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same in-place WRITE_CALENDAR upgrade the detail screen does: a v1.0 install
|
||||||
|
// holds only READ_CALENDAR, and granting continues into the held delete.
|
||||||
|
var pendingWrite by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
|
val writePermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
if (granted) pendingWrite?.invoke()
|
||||||
|
pendingWrite = null
|
||||||
|
}
|
||||||
|
val requireWrite: (() -> Unit) -> Unit = { action ->
|
||||||
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.WRITE_CALENDAR,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
if (granted) {
|
||||||
|
action()
|
||||||
|
} else {
|
||||||
|
pendingWrite = action
|
||||||
|
writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back leaves selection mode before it leaves the screen, and without the
|
||||||
|
// predictive-back scale-out — that would read as search closing and snapping
|
||||||
|
// back. The two handlers are mutually exclusive on [inSelection].
|
||||||
|
BackHandler(enabled = inSelection) { viewModel.clearSelection() }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.predictiveBack(onBack = onBack),
|
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
SearchTopBar(
|
||||||
title = {
|
inSelection = inSelection,
|
||||||
InlineTextField(
|
query = query,
|
||||||
value = query,
|
selectedCount = selection.size,
|
||||||
onValueChange = viewModel::setQuery,
|
focusRequester = focusRequester,
|
||||||
placeholder = stringResource(R.string.search_hint),
|
onQueryChange = viewModel::setQuery,
|
||||||
capitalization = KeyboardCapitalization.None,
|
onImeAction = { keyboard?.hide() },
|
||||||
imeAction = ImeAction.Search,
|
onBack = onBack,
|
||||||
onImeAction = { keyboard?.hide() },
|
onCloseSelection = viewModel::clearSelection,
|
||||||
modifier = Modifier
|
onSelectAll = viewModel::selectAll,
|
||||||
.fillMaxWidth()
|
onDelete = { requireWrite { showDeleteDialog = true } },
|
||||||
.focusRequester(focusRequester),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.search_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (query.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { viewModel.setQuery("") }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.search_clear),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
// imePadding shrinks the content by the keyboard, so the centered
|
// imePadding so the idle/empty message re-centres above the keyboard and
|
||||||
// idle/empty message re-centres in the space above it (and the results
|
// the results list lifts clear of it.
|
||||||
// list lifts clear of the keyboard too).
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -157,75 +218,413 @@ fun SearchScreen(
|
|||||||
text = stringResource(R.string.search_empty, s.query),
|
text = stringResource(R.string.search_empty, s.query),
|
||||||
)
|
)
|
||||||
is SearchUiState.Results -> SearchResults(
|
is SearchUiState.Results -> SearchResults(
|
||||||
events = s.events,
|
results = s,
|
||||||
|
selection = selection,
|
||||||
|
inSelection = inSelection,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
|
onToggle = viewModel::toggleSelection,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DeleteOutcomeChip(
|
||||||
|
deleteState = deleteState,
|
||||||
|
onConsume = viewModel::consumeDeleteResult,
|
||||||
|
modifier = Modifier.align(Alignment.BottomStart),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDeleteDialog) {
|
||||||
|
val count = selection.size
|
||||||
|
// One decision for the whole batch: the scope question is only asked when
|
||||||
|
// a recurring event is in it, and one-offs ignore whatever comes back.
|
||||||
|
if (viewModel.selectionHasRecurring()) {
|
||||||
|
RecurringScopeDialog(
|
||||||
|
title = stringResource(R.string.event_delete_recurring_title),
|
||||||
|
onSelect = { scope ->
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.deleteSelected(scope)
|
||||||
|
},
|
||||||
|
onDismiss = { showDeleteDialog = false },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteDialog = false },
|
||||||
|
title = {
|
||||||
|
Text(pluralStringResource(R.plurals.search_delete_title, count, count))
|
||||||
|
},
|
||||||
|
text = { Text(stringResource(R.string.event_delete_body)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showDeleteDialog = false
|
||||||
|
viewModel.deleteSelected(RecurringWriteScope.AllEvents)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.event_detail_delete),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteDialog = false }) {
|
||||||
|
Text(stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search field and the contextual selection bar as one bar: the bar stays
|
||||||
|
* in place across the swap, easing its colour and fading its slots through.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun SearchTopBar(
|
||||||
|
inSelection: Boolean,
|
||||||
|
query: String,
|
||||||
|
selectedCount: Int,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onImeAction: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onCloseSelection: () -> Unit,
|
||||||
|
onSelectAll: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val container by animateColorAsState(
|
||||||
|
targetValue = if (inSelection) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
},
|
||||||
|
animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||||
|
label = "search-top-bar-color",
|
||||||
|
)
|
||||||
|
// Held past the selection emptying, so the count has something to draw while
|
||||||
|
// it fades out.
|
||||||
|
var lastCount by remember { mutableIntStateOf(0) }
|
||||||
|
if (selectedCount > 0) lastCount = selectedCount
|
||||||
|
|
||||||
|
// Read here so the non-composable transitionSpec lambdas can capture it.
|
||||||
|
val slotSwap = fadeThrough()
|
||||||
|
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = inSelection,
|
||||||
|
transitionSpec = { slotSwap },
|
||||||
|
label = "search-top-bar-title",
|
||||||
|
) { selecting ->
|
||||||
|
if (selecting) {
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.search_selected_count,
|
||||||
|
lastCount,
|
||||||
|
lastCount,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
InlineTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = stringResource(R.string.search_hint),
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
onImeAction = onImeAction,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = inSelection,
|
||||||
|
transitionSpec = { slotSwap },
|
||||||
|
label = "search-top-bar-navigation",
|
||||||
|
) { selecting ->
|
||||||
|
if (selecting) {
|
||||||
|
IconButton(onClick = onCloseSelection) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_selection_close),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.search_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = inSelection,
|
||||||
|
transitionSpec = { slotSwap },
|
||||||
|
label = "search-top-bar-actions",
|
||||||
|
) { selecting ->
|
||||||
|
Row {
|
||||||
|
if (selecting) {
|
||||||
|
IconButton(onClick = onSelectAll) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SelectAll,
|
||||||
|
contentDescription = stringResource(R.string.search_select_all),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
R.string.search_delete_selected,
|
||||||
|
),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (query.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { onQueryChange("") }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.search_clear),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = container),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResults(
|
private fun SearchResults(
|
||||||
events: List<EventInstance>,
|
results: SearchUiState.Results,
|
||||||
|
selection: Set<Long>,
|
||||||
|
inSelection: Boolean,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onToggle: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
|
// No horizontal inset: GroupedRow already insets itself.
|
||||||
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
results.results.months.forEach { month ->
|
||||||
items = events,
|
stickyHeader(key = "month-${month.year}-${month.monthNumber}") {
|
||||||
key = { _, event -> event.eventId },
|
SearchSectionHeader(text = monthLabel(month))
|
||||||
) { index, event ->
|
}
|
||||||
SearchResultRow(
|
hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
|
||||||
event = event,
|
}
|
||||||
position = positionOf(index, events.size),
|
// Held back from the calendar above: matched only in the notes.
|
||||||
modifier = animateItemMotion(),
|
if (results.results.inDescriptions.isNotEmpty()) {
|
||||||
onClick = { onEventClick(event) },
|
stickyHeader(key = "in-descriptions") {
|
||||||
|
SearchSectionHeader(text = stringResource(R.string.search_in_descriptions))
|
||||||
|
}
|
||||||
|
hitRows(
|
||||||
|
results.results.inDescriptions,
|
||||||
|
results,
|
||||||
|
selection,
|
||||||
|
inSelection,
|
||||||
|
onEventClick,
|
||||||
|
onToggle,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One section's rows, sharing a grouped-card run. */
|
||||||
|
private fun LazyListScope.hitRows(
|
||||||
|
hits: List<SearchHit>,
|
||||||
|
results: SearchUiState.Results,
|
||||||
|
selection: Set<Long>,
|
||||||
|
inSelection: Boolean,
|
||||||
|
onEventClick: (EventInstance) -> Unit,
|
||||||
|
onToggle: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
itemsIndexed(
|
||||||
|
items = hits,
|
||||||
|
key = { _, hit -> hit.event.eventId },
|
||||||
|
) { index, hit ->
|
||||||
|
val event = hit.event
|
||||||
|
val deletable = results.isDeletable(event)
|
||||||
|
SearchResultRow(
|
||||||
|
hit = hit,
|
||||||
|
position = positionOf(index, hits.size),
|
||||||
|
modifier = animateItemMotion(),
|
||||||
|
selected = event.eventId in selection,
|
||||||
|
// A read-only calendar's row can't join a batch, so in selection
|
||||||
|
// mode it goes quiet rather than offering a checkbox that fails.
|
||||||
|
inSelection = inSelection,
|
||||||
|
selectable = deletable,
|
||||||
|
onClick = when {
|
||||||
|
inSelection && deletable -> ({ onToggle(event.eventId) })
|
||||||
|
inSelection -> null
|
||||||
|
else -> ({ onEventClick(event) })
|
||||||
|
},
|
||||||
|
onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pinned section label, styled like the agenda's day headers. */
|
||||||
|
@Composable
|
||||||
|
private fun SearchSectionHeader(text: String) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "August 2026" in the locale's own field order. */
|
||||||
|
@Composable
|
||||||
|
private fun monthLabel(month: SearchMonth): String {
|
||||||
|
val locale = currentLocale()
|
||||||
|
return remember(month.year, month.monthNumber, locale) {
|
||||||
|
localizedDateFormatter(locale, "LLLLy")
|
||||||
|
.format(java.time.LocalDate.of(month.year, month.monthNumber, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResultRow(
|
private fun SearchResultRow(
|
||||||
event: EventInstance,
|
hit: SearchHit,
|
||||||
position: Position,
|
position: Position,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
selected: Boolean = false,
|
||||||
|
inSelection: Boolean = false,
|
||||||
|
selectable: Boolean = true,
|
||||||
|
onClick: (() -> Unit)?,
|
||||||
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
val event = hit.event
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
|
// On a picked row the headline is already recoloured for the secondary
|
||||||
|
// container, so the match is carried by weight alone there.
|
||||||
|
val highlight = SpanStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = modifier,
|
// Faded like a past event anywhere else in the app — search reaches back
|
||||||
title = event.title,
|
// through the whole history.
|
||||||
summary = searchSummary(event),
|
modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
|
title = declinedTitle(marked(event.title, hit.titleSpans, highlight), event.isDeclined),
|
||||||
|
summary = searchSummary(hit, highlight),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
|
selected = selected,
|
||||||
|
dimmed = inSelection && !selectable,
|
||||||
leading = {
|
leading = {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(width = 6.dp, height = 36.dp)
|
.size(width = 6.dp, height = 36.dp)
|
||||||
.clip(RoundedCornerShape(3.dp))
|
.clip(RoundedCornerShape(3.dp))
|
||||||
.background(eventFill(event.color, dark, soften)),
|
.background(eventAccent(event.color, dark, soften)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
trailing = if (inSelection && selectable) {
|
||||||
|
{
|
||||||
|
Checkbox(
|
||||||
|
checked = selected,
|
||||||
|
onCheckedChange = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */
|
/**
|
||||||
|
* The batch's receipt: how many went, and whether any refused. No Undo — the
|
||||||
|
* provider can't put a deleted event back.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun searchSummary(event: EventInstance): String {
|
private fun DeleteOutcomeChip(
|
||||||
|
deleteState: BulkDeleteUiState,
|
||||||
|
onConsume: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val message = when (deleteState) {
|
||||||
|
is BulkDeleteUiState.Done -> when {
|
||||||
|
deleteState.failed > 0 -> stringResource(
|
||||||
|
R.string.search_delete_partial,
|
||||||
|
deleteState.deleted,
|
||||||
|
deleteState.failed,
|
||||||
|
)
|
||||||
|
else -> pluralStringResource(
|
||||||
|
R.plurals.search_delete_done,
|
||||||
|
deleteState.deleted,
|
||||||
|
deleteState.deleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is BulkDeleteUiState.NeedsPermission -> if (deleteState.deleted > 0) {
|
||||||
|
stringResource(R.string.search_delete_denied_partial, deleteState.deleted)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.event_delete_write_denied)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
// Held past the state being consumed so the chip has something to draw while
|
||||||
|
// it springs back out.
|
||||||
|
val shown = remember { mutableStateOf("") }
|
||||||
|
if (message != null && shown.value != message) shown.value = message
|
||||||
|
|
||||||
|
LaunchedEffect(deleteState) {
|
||||||
|
if (message == null) return@LaunchedEffect
|
||||||
|
delay(CHIP_MILLIS)
|
||||||
|
onConsume()
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||||
|
.height(SnackChipHeight),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
|
SnackChip(visible = message != null, message = shown.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [text] with every matched run emphasised. */
|
||||||
|
private fun marked(text: String, spans: List<MatchSpan>, style: SpanStyle): AnnotatedString =
|
||||||
|
if (spans.isEmpty()) {
|
||||||
|
AnnotatedString(text)
|
||||||
|
} else {
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(text)
|
||||||
|
spans.forEach { addStyle(style, it.start, it.end) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Wed, 17 Jun 2026 · 09:00 · Office", with a second line excerpting the
|
||||||
|
* description when that is where the query matched.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString {
|
||||||
|
val event = hit.event
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val zone = remember { ZoneId.systemDefault() }
|
val zone = remember { ZoneId.systemDefault() }
|
||||||
val start = remember(event.start, zone) {
|
val start = remember(event.start, zone) {
|
||||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||||
}
|
}
|
||||||
// From the shared span rule, not [start]: an all-day event sits at UTC
|
// From the span rule, not [start]: an all-day event sits at UTC midnight and
|
||||||
// midnight and would name the day before west of UTC (#82). The clock time
|
// would name the day before west of UTC (#82).
|
||||||
// below stays device-zone — it is only rendered for timed events.
|
|
||||||
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
|
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||||
@@ -236,8 +635,23 @@ private fun searchSummary(event: EventInstance): String {
|
|||||||
} else {
|
} else {
|
||||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||||
}
|
}
|
||||||
val base = "$dateText · $timeText"
|
return buildAnnotatedString {
|
||||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
append("$dateText · $timeText")
|
||||||
|
event.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||||
|
append(" · ")
|
||||||
|
val offset = length
|
||||||
|
append(location)
|
||||||
|
hit.locationSpans.forEach {
|
||||||
|
addStyle(highlight, offset + it.start, offset + it.end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hit.descriptionSnippet?.let { snippet ->
|
||||||
|
append("\n")
|
||||||
|
val offset = length
|
||||||
|
append(snippet.text)
|
||||||
|
snippet.spans.forEach { addStyle(highlight, offset + it.start, offset + it.end) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventSearch
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchResults
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
@@ -14,12 +18,19 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -33,8 +44,30 @@ sealed interface SearchUiState {
|
|||||||
/** A query ran but matched nothing. */
|
/** A query ran but matched nothing. */
|
||||||
data class Empty(val query: String) : SearchUiState
|
data class Empty(val query: String) : SearchUiState
|
||||||
|
|
||||||
/** Matches, ordered nearest-to-today first (upcoming ascending, then past descending). */
|
/** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */
|
||||||
data class Results(val events: List<EventInstance>) : SearchUiState
|
data class Results(
|
||||||
|
val results: SearchResults,
|
||||||
|
/** Rows from these are kept out of selection rather than failing later. */
|
||||||
|
val readOnlyCalendarIds: Set<Long> = emptySet(),
|
||||||
|
) : SearchUiState {
|
||||||
|
val events: List<EventInstance> get() = results.allHits.map { it.event }
|
||||||
|
|
||||||
|
fun isDeletable(event: EventInstance): Boolean =
|
||||||
|
event.calendarId !in readOnlyCalendarIds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of deleting the selected results (#80). */
|
||||||
|
sealed interface BulkDeleteUiState {
|
||||||
|
data object Idle : BulkDeleteUiState
|
||||||
|
|
||||||
|
data object Deleting : BulkDeleteUiState
|
||||||
|
|
||||||
|
/** Terminal: [deleted] events went, [failed] wouldn't. */
|
||||||
|
data class Done(val deleted: Int, val failed: Int) : BulkDeleteUiState
|
||||||
|
|
||||||
|
/** WRITE_CALENDAR was revoked after [deleted] went; the rest wasn't tried. */
|
||||||
|
data class NeedsPermission(val deleted: Int) : BulkDeleteUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
|
||||||
@@ -47,17 +80,42 @@ class SearchViewModel @Inject constructor(
|
|||||||
private val _query = MutableStateFlow("")
|
private val _query = MutableStateFlow("")
|
||||||
val query: StateFlow<String> = _query.asStateFlow()
|
val query: StateFlow<String> = _query.asStateFlow()
|
||||||
|
|
||||||
val state: StateFlow<SearchUiState> = _query
|
/** Bumped after a delete so the same query re-runs against the changed provider. */
|
||||||
.debounce(250L)
|
private val _reload = MutableStateFlow(0)
|
||||||
.map { it.trim() }
|
|
||||||
.distinctUntilChanged()
|
private val _selection = MutableStateFlow<Set<Long>>(emptySet())
|
||||||
|
|
||||||
|
/** Event ids picked in selection mode; empty means the mode is off. */
|
||||||
|
val selection: StateFlow<Set<Long>> = _selection.asStateFlow()
|
||||||
|
|
||||||
|
private val _deleteState = MutableStateFlow<BulkDeleteUiState>(BulkDeleteUiState.Idle)
|
||||||
|
val deleteState: StateFlow<BulkDeleteUiState> = _deleteState.asStateFlow()
|
||||||
|
|
||||||
|
val state: StateFlow<SearchUiState> = combine(
|
||||||
|
_query.debounce(250L).map { it.trim() }.distinctUntilChanged(),
|
||||||
|
_reload,
|
||||||
|
) { q, _ -> q }
|
||||||
.mapLatest { q ->
|
.mapLatest { q ->
|
||||||
if (q.length < MIN_QUERY_LENGTH) {
|
if (q.length < MIN_QUERY_LENGTH) {
|
||||||
SearchUiState.Idle
|
SearchUiState.Idle
|
||||||
} else {
|
} else {
|
||||||
val results = repository.searchEvents(q)
|
val now = Clock.System.now()
|
||||||
if (results.isEmpty()) SearchUiState.Empty(q)
|
val zone = TimeZone.currentSystemDefault()
|
||||||
else SearchUiState.Results(sortNearestFirst(results))
|
val results = EventSearch.search(
|
||||||
|
candidates = repository.searchEvents(q),
|
||||||
|
query = q,
|
||||||
|
now = now,
|
||||||
|
todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone),
|
||||||
|
zone = zone,
|
||||||
|
)
|
||||||
|
if (results.isEmpty) {
|
||||||
|
SearchUiState.Empty(q)
|
||||||
|
} else {
|
||||||
|
SearchUiState.Results(
|
||||||
|
results = results,
|
||||||
|
readOnlyCalendarIds = readOnlyCalendarIds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(SearchUiState.Idle) }
|
.catch { emit(SearchUiState.Idle) }
|
||||||
@@ -65,13 +123,94 @@ class SearchViewModel @Inject constructor(
|
|||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), SearchUiState.Idle)
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
|
if (value != _query.value) _selection.value = emptySet()
|
||||||
_query.value = value
|
_query.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soonest upcoming (and ongoing) first, then the most recent past. */
|
/** Toggle one result; the first pick is what turns selection mode on. */
|
||||||
private fun sortNearestFirst(events: List<EventInstance>): List<EventInstance> {
|
fun toggleSelection(eventId: Long) {
|
||||||
val now = Clock.System.now()
|
val current = _selection.value
|
||||||
val (upcoming, past) = events.partition { it.end >= now }
|
_selection.value = if (eventId in current) current - eventId else current + eventId
|
||||||
return upcoming.sortedBy { it.start } + past.sortedByDescending { it.start }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pick every result that lives in a writable calendar. */
|
||||||
|
fun selectAll() {
|
||||||
|
val results = state.value as? SearchUiState.Results ?: return
|
||||||
|
_selection.value = results.events.filter(results::isDeletable).map { it.eventId }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leave selection mode. */
|
||||||
|
fun clearSelection() {
|
||||||
|
_selection.value = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the batch needs a [RecurringWriteScope] decision before it runs. */
|
||||||
|
fun selectionHasRecurring(): Boolean {
|
||||||
|
val results = state.value as? SearchUiState.Results ?: return false
|
||||||
|
val picked = _selection.value
|
||||||
|
return results.events.any { it.eventId in picked && it.isRecurring }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete every selected event. [scope] reaches the recurring ones only; a
|
||||||
|
* one-off always goes whole. One at a time, so a single failure doesn't take
|
||||||
|
* the rest with it — the tally lands in [deleteState].
|
||||||
|
*/
|
||||||
|
fun deleteSelected(scope: RecurringWriteScope) {
|
||||||
|
if (_deleteState.value == BulkDeleteUiState.Deleting) return
|
||||||
|
val results = state.value as? SearchUiState.Results ?: return
|
||||||
|
val picked = _selection.value
|
||||||
|
val targets = results.events.filter { it.eventId in picked && results.isDeletable(it) }
|
||||||
|
if (targets.isEmpty()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_deleteState.value = BulkDeleteUiState.Deleting
|
||||||
|
var deleted = 0
|
||||||
|
var failed = 0
|
||||||
|
var denied = false
|
||||||
|
for (event in targets) {
|
||||||
|
try {
|
||||||
|
withContext(io) { deleteOne(event, scope) }
|
||||||
|
deleted++
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
denied = true
|
||||||
|
break
|
||||||
|
} catch (e: Exception) {
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_selection.value = emptySet()
|
||||||
|
_reload.value += 1
|
||||||
|
_deleteState.value = if (denied) {
|
||||||
|
BulkDeleteUiState.NeedsPermission(deleted = deleted)
|
||||||
|
} else {
|
||||||
|
BulkDeleteUiState.Done(deleted = deleted, failed = failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset [deleteState] after the screen showed the outcome. */
|
||||||
|
fun consumeDeleteResult() {
|
||||||
|
_deleteState.value = BulkDeleteUiState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun deleteOne(event: EventInstance, scope: RecurringWriteScope) {
|
||||||
|
val begin = event.start.toEpochMilliseconds()
|
||||||
|
when {
|
||||||
|
!event.isRecurring -> repository.deleteEvent(event.eventId)
|
||||||
|
scope == RecurringWriteScope.ThisEvent ->
|
||||||
|
repository.deleteOccurrence(event.eventId, begin)
|
||||||
|
scope == RecurringWriteScope.ThisAndFollowing ->
|
||||||
|
repository.deleteEventFromOccurrence(event.eventId, begin)
|
||||||
|
else -> repository.deleteEvent(event.eventId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readOnlyCalendarIds(): Set<Long> =
|
||||||
|
repository.calendars().first()
|
||||||
|
.filterNot { it.canModifyContents }
|
||||||
|
.map { it.id }
|
||||||
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Keyboard
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
|
||||||
/** New event form: which fields it opens with, and how it behaves. */
|
/** New event form: which fields it opens with, and how it behaves. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -27,6 +42,13 @@ internal fun EventFormScreen(
|
|||||||
viewModel: SettingsViewModel,
|
viewModel: SettingsViewModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
var showDefaultDuration by remember { mutableStateOf(false) }
|
||||||
|
// The calendar whose duration-override picker is open, if any.
|
||||||
|
var durationTarget by remember { mutableStateOf<Long?>(null) }
|
||||||
|
var durationSectionExpanded by remember { mutableStateOf(false) }
|
||||||
|
// Special-dates calendars carry all-day events only, which have no length.
|
||||||
|
val durationCalendars = state.writableCalendars.filterNot { it.id in state.managedCalendarIds }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_event_form),
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
@@ -80,6 +102,64 @@ internal fun EventFormScreen(
|
|||||||
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// How long a new timed event opens (#54), globally and per calendar —
|
||||||
|
// the per-calendar list folds behind its own header, like the reminders.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_event_duration),
|
||||||
|
summary = durationLabel(state.defaultEventDurationMinutes),
|
||||||
|
position = if (durationCalendars.isEmpty()) Position.Alone else Position.Top,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { showDefaultDuration = true },
|
||||||
|
)
|
||||||
|
if (durationCalendars.isNotEmpty()) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_calendar_durations_title),
|
||||||
|
summary = stringResource(R.string.settings_calendar_durations_hint),
|
||||||
|
// Expanded, the header opens the calendar run below it and the
|
||||||
|
// two read as one container (the shared expand pattern).
|
||||||
|
position = if (durationSectionExpanded) Position.Top else Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (durationSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { durationSectionExpanded = !durationSectionExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = durationSectionExpanded,
|
||||||
|
enter = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
durationCalendars.forEachIndexed { index, calendar ->
|
||||||
|
val override = state.perCalendarEventDuration[calendar.id]
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
summary = override?.let { durationLabel(it) }
|
||||||
|
?: stringResource(
|
||||||
|
R.string.settings_calendar_duration_inherits,
|
||||||
|
durationLabel(state.defaultEventDurationMinutes),
|
||||||
|
),
|
||||||
|
// The header above is the run's first row, so the
|
||||||
|
// calendars continue it: middles, then its bottom.
|
||||||
|
position = positionOf(index + 1, durationCalendars.size + 1),
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
onClick = { durationTarget = calendar.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Per-event colour on calendars that publish no colour set (some
|
// Per-event colour on calendars that publish no colour set (some
|
||||||
// CalDAV); off by default, since it may not survive their next sync.
|
// CalDAV); off by default, since it may not survive their next sync.
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
@@ -100,4 +180,36 @@ internal fun EventFormScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDefaultDuration) {
|
||||||
|
DurationPicker(
|
||||||
|
title = stringResource(R.string.settings_event_duration),
|
||||||
|
description = stringResource(R.string.settings_event_duration_hint),
|
||||||
|
presets = EVENT_DURATION_PRESETS,
|
||||||
|
selected = state.defaultEventDurationMinutes,
|
||||||
|
label = { durationLabel(it) },
|
||||||
|
// No inherit row on the global default, so a pick is never null.
|
||||||
|
onSelect = { minutes -> minutes?.let { viewModel.setDefaultEventDuration(it) } },
|
||||||
|
onDismiss = { showDefaultDuration = false },
|
||||||
|
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
durationTarget?.let { calendarId ->
|
||||||
|
DurationPicker(
|
||||||
|
title = stringResource(R.string.settings_event_duration),
|
||||||
|
presets = EVENT_DURATION_PRESETS,
|
||||||
|
selected = state.perCalendarEventDuration[calendarId],
|
||||||
|
label = { durationLabel(it) },
|
||||||
|
inheritLabel = stringResource(
|
||||||
|
R.string.settings_calendar_duration_use_default,
|
||||||
|
durationLabel(state.defaultEventDurationMinutes),
|
||||||
|
),
|
||||||
|
onSelect = { viewModel.setCalendarEventDuration(calendarId, it) },
|
||||||
|
onDismiss = { durationTarget = null },
|
||||||
|
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lengths offered for a new timed event, in minutes — up to a full work day. */
|
||||||
|
private val EVENT_DURATION_PRESETS = listOf(15, 30, 45, 60, 90, 120, 240, 480)
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.core.snap
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||||
@@ -27,7 +16,6 @@ import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,39 +39,21 @@ internal fun MonthViewStylePicker(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val options = MonthViewStyle.entries
|
val options = MonthViewStyle.entries
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.settings_month_view_style),
|
title = stringResource(R.string.settings_month_view_style),
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
Box(
|
ViewPreviewFrame(
|
||||||
modifier = Modifier
|
selected = selected,
|
||||||
.fillMaxWidth()
|
label = "month-style-preview",
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
.height(PREVIEW_HEIGHT),
|
) { shown ->
|
||||||
contentAlignment = Alignment.Center,
|
MonthStylePreview(
|
||||||
) {
|
style = shown,
|
||||||
Crossfade(
|
weekStart = weekStart,
|
||||||
targetState = selected,
|
height = PREVIEW_HEIGHT,
|
||||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
)
|
||||||
label = "month-style-preview",
|
|
||||||
) { shown ->
|
|
||||||
// The preview renders the real grid at phone size and scales it
|
|
||||||
// down, so its own corners are square — the frame rounds it.
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(PREVIEW_SHAPE)
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.clipToBounds(),
|
|
||||||
) {
|
|
||||||
MonthStylePreview(
|
|
||||||
style = shown,
|
|
||||||
weekStart = weekStart,
|
|
||||||
height = PREVIEW_HEIGHT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// The selected style's own blurb lives here, under its preview, rather
|
// The selected style's own blurb lives here, under its preview, rather
|
||||||
// than on every row — the rows stay single-line and dense, and the words
|
// than on every row — the rows stay single-line and dense, and the words
|
||||||
@@ -109,4 +79,3 @@ internal fun MonthViewStylePicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val PREVIEW_HEIGHT = 280.dp
|
private val PREVIEW_HEIGHT = 280.dp
|
||||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -38,10 +37,11 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
@@ -146,7 +146,7 @@ internal fun NotificationsScreen(
|
|||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
summary = snoozeDurationLabel(state.snoozeMinutes),
|
summary = durationLabel(state.snoozeMinutes),
|
||||||
position = Position.Bottom,
|
position = Position.Bottom,
|
||||||
onClick = { showSnooze = true },
|
onClick = { showSnooze = true },
|
||||||
)
|
)
|
||||||
@@ -245,12 +245,13 @@ internal fun NotificationsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showSnooze) {
|
if (showSnooze) {
|
||||||
SnoozeDurationPicker(
|
DurationPicker(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
presets = SNOOZE_PRESETS,
|
presets = SNOOZE_PRESETS,
|
||||||
selected = state.snoozeMinutes,
|
selected = state.snoozeMinutes,
|
||||||
label = { snoozeDurationLabel(it) },
|
label = { durationLabel(it) },
|
||||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
// No inherit row here, so a pick is never null.
|
||||||
|
onSelect = { minutes -> minutes?.let { viewModel.setSnoozeMinutes(it) } },
|
||||||
onDismiss = { showSnooze = false },
|
onDismiss = { showSnooze = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -348,15 +349,6 @@ private fun calendarOverrideSummary(
|
|||||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||||
|
|
||||||
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
|
||||||
@Composable
|
|
||||||
private fun snoozeDurationLabel(minutes: Int): String =
|
|
||||||
if (minutes % 60 == 0) {
|
|
||||||
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
|
||||||
} else {
|
|
||||||
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||||
|
|||||||
@@ -302,13 +302,14 @@ private fun ReportProblemRow(position: Position) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun LanguageRow(position: Position) {
|
private fun LanguageRow(position: Position) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val supported = remember { AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||||
// Setting a locale recreates the activity; mirror the choice locally so the
|
// Setting a locale recreates the activity; mirror the choice locally so the
|
||||||
// row updates instantly even before the recreation lands.
|
// row updates instantly even before the recreation lands.
|
||||||
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
|
var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) }
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
val options = remember(supported) { listOf<String?>(null) + supported }
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||||
|
|
||||||
@@ -41,6 +42,8 @@ data class SettingsUiState(
|
|||||||
val showWeekNumbers: Boolean = false,
|
val showWeekNumbers: Boolean = false,
|
||||||
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
/** Whether the jump-to-today control sits in the top bar instead of the FAB (#60). */
|
||||||
val todayButtonInToolbar: Boolean = false,
|
val todayButtonInToolbar: Boolean = false,
|
||||||
|
/** Whether events can be dragged to another slot to reschedule them (#68, #173). */
|
||||||
|
val dragToReschedule: Boolean = true,
|
||||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
@@ -57,12 +60,18 @@ data class SettingsUiState(
|
|||||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||||
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
||||||
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||||
|
/** How tall an hour is drawn in the week and day timelines (#56). */
|
||||||
|
val timelineScale: TimelineScale = TimelineScale.Regular,
|
||||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||||
val autofocusEventTitle: Boolean = true,
|
val autofocusEventTitle: Boolean = true,
|
||||||
|
/** How long a new timed event lasts, in minutes (#54); all-day events ignore it. */
|
||||||
|
val defaultEventDurationMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION,
|
||||||
|
/** Per-calendar overrides of [defaultEventDurationMinutes]; absent = inherit. */
|
||||||
|
val perCalendarEventDuration: Map<Long, Int> = emptyMap(),
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||||
@@ -123,8 +124,14 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.perCalendarAllDayReminderOverride,
|
prefs.perCalendarAllDayReminderOverride,
|
||||||
writableCalendars,
|
writableCalendars,
|
||||||
prefs.managedCalendarIds,
|
prefs.managedCalendarIds,
|
||||||
) { overrides, allDayOverrides, calendars, managedIds ->
|
// The new-event duration defaults (#54) fold into one flow so they
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
// fit this group — the outer combine is at its five-arg limit.
|
||||||
|
combine(
|
||||||
|
prefs.defaultEventDurationMinutes,
|
||||||
|
prefs.perCalendarEventDuration,
|
||||||
|
) { duration, perCalendar -> EventDurations(duration, perCalendar) },
|
||||||
|
) { overrides, allDayOverrides, calendars, managedIds, durations ->
|
||||||
|
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds, durations)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
prefs.defaultView,
|
prefs.defaultView,
|
||||||
@@ -138,9 +145,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.showWeekNumbers,
|
prefs.showWeekNumbers,
|
||||||
prefs.agendaShowToday,
|
prefs.agendaShowToday,
|
||||||
prefs.softenCalendarColors,
|
prefs.softenCalendarColors,
|
||||||
prefs.todayButtonInToolbar,
|
combine(
|
||||||
) { hourLines, weekNumbers, showToday, soften, todayInToolbar ->
|
prefs.todayButtonInToolbar,
|
||||||
DisplayToggles(hourLines, weekNumbers, showToday, soften, todayInToolbar)
|
prefs.dragToReschedule,
|
||||||
|
::Pair,
|
||||||
|
),
|
||||||
|
) { hourLines, weekNumbers, showToday, soften, (todayInToolbar, dragToMove) ->
|
||||||
|
DisplayToggles(
|
||||||
|
hourLines, weekNumbers, showToday, soften, todayInToolbar, dragToMove,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
) { view, screenRange, widgetRange, timeFormat, toggles ->
|
||||||
ViewSettings(
|
ViewSettings(
|
||||||
@@ -150,6 +163,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
agendaShowToday = toggles.agendaShowToday,
|
agendaShowToday = toggles.agendaShowToday,
|
||||||
softenColors = toggles.softenColors,
|
softenColors = toggles.softenColors,
|
||||||
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
todayButtonInToolbar = toggles.todayButtonInToolbar,
|
||||||
|
dragToReschedule = toggles.dragToReschedule,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
combine(
|
combine(
|
||||||
@@ -164,8 +178,9 @@ class SettingsViewModel @Inject constructor(
|
|||||||
prefs.drawerViewOrder,
|
prefs.drawerViewOrder,
|
||||||
prefs.monthViewStyle,
|
prefs.monthViewStyle,
|
||||||
prefs.widgetSize,
|
prefs.widgetSize,
|
||||||
) { quickSwitch, drawer, monthStyle, widgetSize ->
|
prefs.timelineScale,
|
||||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
|
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
||||||
|
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
||||||
},
|
},
|
||||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
@@ -181,6 +196,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
agendaShowToday = views.agendaShowToday,
|
agendaShowToday = views.agendaShowToday,
|
||||||
softenColors = views.softenColors,
|
softenColors = views.softenColors,
|
||||||
todayButtonInToolbar = views.todayButtonInToolbar,
|
todayButtonInToolbar = views.todayButtonInToolbar,
|
||||||
|
dragToReschedule = views.dragToReschedule,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
pastEventDisplay = misc.pastEventDisplay,
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
@@ -189,6 +205,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||||
widgetSize = misc.viewCustomization.widgetSize,
|
widgetSize = misc.viewCustomization.widgetSize,
|
||||||
|
timelineScale = misc.viewCustomization.timelineScale,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -198,6 +215,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||||
writableCalendars = overrides.calendars,
|
writableCalendars = overrides.calendars,
|
||||||
managedCalendarIds = overrides.managedIds,
|
managedCalendarIds = overrides.managedIds,
|
||||||
|
defaultEventDurationMinutes = overrides.durations.default,
|
||||||
|
perCalendarEventDuration = overrides.durations.perCalendar,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
@@ -260,6 +279,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val allDay: Map<Long, List<Int>>,
|
val allDay: Map<Long, List<Int>>,
|
||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
val managedIds: Set<Long>,
|
val managedIds: Set<Long>,
|
||||||
|
val durations: EventDurations,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class EventDurations(
|
||||||
|
val default: Int,
|
||||||
|
val perCalendar: Map<Long, Int>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ViewSettings(
|
private data class ViewSettings(
|
||||||
@@ -272,6 +297,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
|
val dragToReschedule: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class DisplayToggles(
|
private data class DisplayToggles(
|
||||||
@@ -280,6 +306,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val agendaShowToday: Boolean,
|
val agendaShowToday: Boolean,
|
||||||
val softenColors: Boolean,
|
val softenColors: Boolean,
|
||||||
val todayButtonInToolbar: Boolean,
|
val todayButtonInToolbar: Boolean,
|
||||||
|
val dragToReschedule: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
@@ -295,6 +322,7 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val drawerOrder: List<CalendarView>,
|
val drawerOrder: List<CalendarView>,
|
||||||
val monthViewStyle: MonthViewStyle,
|
val monthViewStyle: MonthViewStyle,
|
||||||
val widgetSize: WidgetSize,
|
val widgetSize: WidgetSize,
|
||||||
|
val timelineScale: TimelineScale,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||||
@@ -516,6 +544,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
viewModelScope.launch { prefs.setTodayButtonInToolbar(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDragToReschedule(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setDragToReschedule(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch the launcher label (#44). The card highlights at once, then settles
|
* Switch the launcher label (#44). The card highlights at once, then settles
|
||||||
* on what the component state reports; the writes are binder round-trips and
|
* on what the component state reports; the writes are binder round-trips and
|
||||||
@@ -581,6 +613,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTimelineScale(scale: TimelineScale) {
|
||||||
|
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
}
|
}
|
||||||
@@ -600,6 +636,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setDefaultEventDuration(minutes: Int) {
|
||||||
|
viewModelScope.launch { prefs.setDefaultEventDurationMinutes(minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [minutes] null clears the override, so the calendar inherits the default. */
|
||||||
|
fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
||||||
|
viewModelScope.launch { prefs.setCalendarEventDuration(calendarId, minutes) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
@@ -68,6 +70,7 @@ internal fun ViewsScreen(
|
|||||||
var showTimeFormat by remember { mutableStateOf(false) }
|
var showTimeFormat by remember { mutableStateOf(false) }
|
||||||
var showPastEvents by remember { mutableStateOf(false) }
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
|
var showTimelineScale by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_views),
|
title = stringResource(R.string.settings_section_views),
|
||||||
@@ -111,7 +114,7 @@ internal fun ViewsScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_dim_completed),
|
title = stringResource(R.string.settings_dim_completed),
|
||||||
summary = stringResource(R.string.settings_dim_completed_summary),
|
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||||
position = Position.Bottom,
|
position = Position.Middle,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.dimCompletedEvents,
|
checked = state.dimCompletedEvents,
|
||||||
@@ -120,6 +123,18 @@ internal fun ViewsScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_drag_to_reschedule),
|
||||||
|
summary = stringResource(R.string.settings_drag_to_reschedule_summary),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.dragToReschedule,
|
||||||
|
onCheckedChange = viewModel::setDragToReschedule,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setDragToReschedule(!state.dragToReschedule) },
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_month_header))
|
SectionHeader(stringResource(R.string.settings_month_header))
|
||||||
@@ -144,10 +159,16 @@ internal fun ViewsScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
SectionHeader(stringResource(R.string.settings_week_day_header))
|
SectionHeader(stringResource(R.string.settings_week_day_header))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_timeline_scale),
|
||||||
|
summary = stringResource(state.timelineScale.labelRes),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showTimelineScale = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_hour_lines),
|
title = stringResource(R.string.settings_hour_lines),
|
||||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||||
position = Position.Alone,
|
position = Position.Bottom,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.showHourLines,
|
checked = state.showHourLines,
|
||||||
@@ -321,6 +342,25 @@ internal fun ViewsScreen(
|
|||||||
onDismiss = { showPastEvents = false },
|
onDismiss = { showPastEvents = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showTimelineScale) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_timeline_scale),
|
||||||
|
header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) },
|
||||||
|
predictiveBack = true,
|
||||||
|
// A height pinched on the timeline is listed alongside the presets
|
||||||
|
// rather than left as a silently unticked list: it is the current
|
||||||
|
// setting, so it has to be visible here, and seeing it next to the
|
||||||
|
// named steps is what makes "tap one to go back" obvious.
|
||||||
|
options = TimelineScale.presets + listOfNotNull(
|
||||||
|
state.timelineScale as? TimelineScale.Custom,
|
||||||
|
),
|
||||||
|
selected = state.timelineScale,
|
||||||
|
label = { stringResource(it.labelRes) },
|
||||||
|
summary = { stringResource(it.descriptionRes) },
|
||||||
|
onSelect = viewModel::setTimelineScale,
|
||||||
|
onDismiss = { showTimelineScale = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showAgendaScreenRange) {
|
if (showAgendaScreenRange) {
|
||||||
AgendaRangePicker(
|
AgendaRangePicker(
|
||||||
title = stringResource(R.string.settings_agenda_range),
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
|||||||
@@ -1,33 +1,21 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
|
||||||
import androidx.compose.animation.core.snap
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ViewPreviewFrame
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,38 +34,22 @@ internal fun WeekStartPicker(
|
|||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val reduceMotion = rememberReduceMotion()
|
|
||||||
val resolved = selected.resolveFirstDay(locale)
|
val resolved = selected.resolveFirstDay(locale)
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
Box(
|
ViewPreviewFrame(
|
||||||
modifier = Modifier
|
selected = resolved,
|
||||||
.fillMaxWidth()
|
label = "week-start-preview",
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
.height(PREVIEW_HEIGHT),
|
) { day ->
|
||||||
contentAlignment = Alignment.Center,
|
MonthStylePreview(
|
||||||
) {
|
style = monthStyle,
|
||||||
Crossfade(
|
weekStart = day,
|
||||||
targetState = resolved,
|
height = PREVIEW_HEIGHT,
|
||||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
)
|
||||||
label = "week-start-preview",
|
|
||||||
) { day ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(PREVIEW_SHAPE)
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.clipToBounds(),
|
|
||||||
) {
|
|
||||||
MonthStylePreview(
|
|
||||||
style = monthStyle,
|
|
||||||
weekStart = day,
|
|
||||||
height = PREVIEW_HEIGHT,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
PickerDescription(stringResource(R.string.settings_week_start_hint))
|
PickerDescription(stringResource(R.string.settings_week_start_hint))
|
||||||
options.forEachIndexed { index, option ->
|
options.forEachIndexed { index, option ->
|
||||||
@@ -107,4 +79,3 @@ internal fun WeekStartPicker(
|
|||||||
|
|
||||||
/** Short enough to leave the first options on screen under it. */
|
/** Short enough to leave the first options on screen under it. */
|
||||||
private val PREVIEW_HEIGHT = 200.dp
|
private val PREVIEW_HEIGHT = 200.dp
|
||||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ 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.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -62,13 +64,17 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -77,12 +83,29 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
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.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||||
|
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.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.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||||
@@ -97,9 +120,19 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
|||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
|
||||||
|
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.formatMinuteOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
|
||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||||
@@ -113,20 +146,14 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private val HOUR_HEIGHT = 56.dp
|
|
||||||
private val GUTTER_WIDTH = 48.dp
|
|
||||||
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
|
||||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
|
||||||
* (the app bar's 4dp inset + 24dp half icon button). */
|
|
||||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
|
||||||
private val MIN_EVENT_HEIGHT = 24.dp
|
|
||||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
|
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||||
|
private val COLUMN_GAP = 2.dp
|
||||||
|
|
||||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||||
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
internal fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||||
if (allDaySpans.isEmpty()) return 0.dp
|
if (allDaySpans.isEmpty()) return 0.dp
|
||||||
val lanes = allDaySpans.maxOf { it.lane } + 1
|
val lanes = allDaySpans.maxOf { it.lane } + 1
|
||||||
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
return ALL_DAY_ROW_HEIGHT * lanes + ALL_DAY_VERTICAL_PADDING * 2
|
||||||
@@ -216,6 +243,7 @@ fun WeekScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = weekStart,
|
currentDate = weekStart,
|
||||||
|
drawerState = drawerState,
|
||||||
viewOrder = drawerViewOrder,
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
@@ -242,6 +270,7 @@ fun WeekScreen(
|
|||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -294,7 +323,6 @@ private fun WeekContent(
|
|||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
val fadeSpec = rememberCalendarFadeSpec()
|
val fadeSpec = rememberCalendarFadeSpec()
|
||||||
val reduceMotion = rememberReduceMotion()
|
val reduceMotion = rememberReduceMotion()
|
||||||
@@ -306,11 +334,9 @@ private fun WeekContent(
|
|||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
val maxV = scrollState.maxValue
|
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||||
val target = with(density) {
|
// centring the range centres midday at whatever hour height is in force.
|
||||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
}.coerceIn(0, maxV)
|
|
||||||
scrollState.scrollTo(target)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||||
@@ -328,44 +354,69 @@ private fun WeekContent(
|
|||||||
// gestures coexist without fighting.
|
// gestures coexist without fighting.
|
||||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||||
|
|
||||||
AnimatedContent(
|
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||||
targetState = state,
|
// floating block inside the outgoing page.
|
||||||
modifier = modifier.then(swipeModifier),
|
val dragController = rememberTimelineDragController()
|
||||||
contentKey = { s ->
|
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 WeekUiState.Success -> "success-${s.weekStart}"
|
||||||
|
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||||
|
WeekUiState.Loading -> "loading"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||||
|
label = "week-transition",
|
||||||
|
) { s ->
|
||||||
when (s) {
|
when (s) {
|
||||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
WeekUiState.Loading -> WeekLoading()
|
||||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
WeekUiState.Loading -> "loading"
|
is WeekUiState.Success -> WeekSuccess(
|
||||||
|
state = s,
|
||||||
|
topSectionColor = topSectionColor,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = allDayHeight,
|
||||||
|
dragController = dragController,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
onOpenDay = onOpenDay,
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
|
||||||
label = "week-transition",
|
|
||||||
) { s ->
|
|
||||||
when (s) {
|
|
||||||
WeekUiState.Loading -> WeekLoading()
|
|
||||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
|
||||||
is WeekUiState.Success -> WeekSuccess(
|
|
||||||
state = s,
|
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
scrollState = scrollState,
|
|
||||||
allDayHeight = allDayHeight,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
onOpenDay = onOpenDay,
|
|
||||||
onCreateAt = onCreateAt,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
TimelineDragOverlay(dragController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekSuccess(
|
internal fun WeekSuccess(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
topSectionColor: Color,
|
topSectionColor: Color,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -382,8 +433,10 @@ private fun WeekSuccess(
|
|||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,6 +450,7 @@ private fun WeekTopBar(
|
|||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||||
@@ -404,9 +458,10 @@ private fun WeekTopBar(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
CalendarTitleButton(
|
||||||
text = formatWeekTitle(weekStart, locale, currentYear),
|
title = formatWeekTitle(weekStart, locale, currentYear),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
currentDate = weekStart,
|
||||||
|
onJumpToDate = onJumpToDate,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -453,7 +508,7 @@ private fun WeekDayHeader(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 4.dp, bottom = 8.dp),
|
.padding(top = 4.dp, bottom = 8.dp, end = TIMELINE_CONTENT_END_INSET),
|
||||||
) {
|
) {
|
||||||
// Mirror the day-column layout (empty weekday line + spacer) so the
|
// Mirror the day-column layout (empty weekday line + spacer) so the
|
||||||
// badge lines up vertically with the date numbers. The start inset centres
|
// badge lines up vertically with the date numbers. The start inset centres
|
||||||
@@ -549,7 +604,11 @@ private fun AllDayStrip(
|
|||||||
// Height is hoisted + animated so it slides and resizes smoothly;
|
// Height is hoisted + animated so it slides and resizes smoothly;
|
||||||
// padding sits inside it so the content area is lanes * row height.
|
// padding sits inside it so the content area is lanes * row height.
|
||||||
.height(height)
|
.height(height)
|
||||||
.padding(vertical = ALL_DAY_VERTICAL_PADDING),
|
.padding(
|
||||||
|
top = ALL_DAY_VERTICAL_PADDING,
|
||||||
|
bottom = ALL_DAY_VERTICAL_PADDING,
|
||||||
|
end = TIMELINE_CONTENT_END_INSET,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
// Keep the gutter-width offset so the bars line up with the day columns.
|
// Keep the gutter-width offset so the bars line up with the day columns.
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
@@ -609,6 +668,7 @@ private fun AllDayBar(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,62 +677,68 @@ private fun AllDayBar(
|
|||||||
private fun Timeline(
|
private fun Timeline(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val zoom = LocalTimelineZoom.current
|
||||||
val locale = currentLocale()
|
val density = LocalDensity.current
|
||||||
|
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
// 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, day 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 columns are two scroll viewports that SHARE one scroll
|
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||||
// soft corners are permanent at any scroll position (not just at the
|
// soft corners are permanent at any scroll position (not just at the
|
||||||
// day's start/end).
|
// day's start/end).
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||||
// as the header badge so the labels sit under it and on the hamburger.
|
// as the header badge so the labels sit under it and on the hamburger.
|
||||||
Column(
|
HourGutter(
|
||||||
modifier = Modifier
|
scrollState = scrollState,
|
||||||
.width(GUTTER_WIDTH)
|
hourHeight = hourHeight,
|
||||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
dragController = dragController,
|
||||||
.fillMaxHeight()
|
)
|
||||||
.verticalScroll(scrollState),
|
|
||||||
) {
|
|
||||||
(0 until 24).forEach { h ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(HOUR_HEIGHT),
|
|
||||||
) {
|
|
||||||
if (h > 0) {
|
|
||||||
Text(
|
|
||||||
text = formatHourLabel(h, use24Hour, locale),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.offset(y = (-6).dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState)
|
||||||
|
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(totalHeight),
|
.height(totalHeight)
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
// The scrolling content itself, so its root position
|
||||||
|
// already folds in the scroll offset.
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
val gap = with(density) { COLUMN_GAP.toPx() }
|
||||||
|
dragController.geometry.let {
|
||||||
|
it.grid = coords
|
||||||
|
it.scroll = scrollState
|
||||||
|
it.hourPx = with(density) { hourHeight.toPx() }
|
||||||
|
it.columnGapPx = gap
|
||||||
|
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||||
|
it.days = state.days
|
||||||
|
it.isRtl = isRtl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
||||||
) {
|
) {
|
||||||
state.days.forEach { day ->
|
state.days.forEach { day ->
|
||||||
DayColumnCard(
|
DayColumnCard(
|
||||||
@@ -680,8 +746,11 @@ private fun Timeline(
|
|||||||
dark = dark,
|
dark = dark,
|
||||||
date = day,
|
date = day,
|
||||||
today = state.today,
|
today = state.today,
|
||||||
|
hourHeight = hourHeight,
|
||||||
|
dragController = dragController,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onCreateAt = onCreateAt,
|
onCreateAt = onCreateAt,
|
||||||
|
onDrop = onDrop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
@@ -699,13 +768,26 @@ private fun DayColumnCard(
|
|||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
|
hourHeight: Dp,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onCreateAt: (LocalDate, Int) -> Unit,
|
onCreateAt: (LocalDate, Int) -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||||
val showHourLines = LocalShowHourLines.current
|
val showHourLines = LocalShowHourLines.current
|
||||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
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(
|
Card(
|
||||||
// Plain rectangular columns — the soft corners come from the outer
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -725,32 +807,52 @@ private fun DayColumnCard(
|
|||||||
// blocks are consumed by their own handler first. Snaps to hour.
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
detectTapGestures { offset ->
|
detectTapGestures { offset ->
|
||||||
val hour = (offset.y / hourPx).toInt().coerceIn(0, 23)
|
currentOnCreateAt.value(
|
||||||
onCreateAt(date, hour * 60)
|
date,
|
||||||
|
tappedMinuteOfDay(offset.y, currentHourPx.value),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
val colWidth = maxWidth
|
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 ->
|
blocks.forEach { block ->
|
||||||
val laneWidth = colWidth / block.laneCount
|
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
key(block.event.eventId, ordinal) {
|
||||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
val laneWidth = colWidth / block.laneCount
|
||||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
val top = hourHeight * (block.startMin / 60f)
|
||||||
EventBlock(
|
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||||
block = block,
|
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||||
dark = dark,
|
val place = animatedBlockPlacement(
|
||||||
height = height,
|
x = laneWidth * block.lane,
|
||||||
onClick = { onEventClick(block.event) },
|
y = top,
|
||||||
modifier = Modifier
|
width = laneWidth,
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
height = height,
|
||||||
.width(laneWidth)
|
)
|
||||||
.height(height)
|
EventBlock(
|
||||||
.padding(horizontal = 1.dp),
|
block = block,
|
||||||
)
|
dark = dark,
|
||||||
|
height = place.height,
|
||||||
|
width = place.width,
|
||||||
|
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.
|
// Current-time line, on top of the events, only on today's column.
|
||||||
if (date == today) {
|
if (date == today) {
|
||||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
NowLine(date = date, hourHeight = hourHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,7 +863,11 @@ private fun EventBlock(
|
|||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
|
width: Dp,
|
||||||
|
date: LocalDate,
|
||||||
|
dragController: TimelineDragController,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onDrop: (TimelineDrop) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
@@ -769,10 +875,6 @@ private fun EventBlock(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
|
||||||
// On narrow overlapping columns we drop it so the title can wrap to fill the
|
|
||||||
// whole block, mirroring Google Calendar.
|
|
||||||
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
|
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val titleLineHeight = with(density) {
|
val titleLineHeight = with(density) {
|
||||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||||
@@ -780,37 +882,79 @@ private fun EventBlock(
|
|||||||
val timeLineHeight = with(density) {
|
val timeLineHeight = with(density) {
|
||||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
}
|
}
|
||||||
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
// What's left for text once the 2.dp top/bottom padding is paid for.
|
||||||
// top/bottom padding and the reserved time line) instead of clipping it to a
|
val available = height - 4.dp
|
||||||
// single character on slim, overlapping blocks.
|
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||||
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||||
|
// afford both lines spends its space on the title.
|
||||||
|
val showTime = block.endMin - block.startMin >= 45 &&
|
||||||
|
block.laneCount == 1 &&
|
||||||
|
available >= titleLineHeight + timeLineHeight
|
||||||
|
// A short block drops the title rather than serving a horizontally sliced
|
||||||
|
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||||
|
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||||
|
// the semantics description carries the full title either way.
|
||||||
|
val showTitle = available >= titleLineHeight
|
||||||
|
// Wrap the title across as many lines as the block can fit — but only once a
|
||||||
|
// line is wide enough to hold more than a syllable. Below that the extra
|
||||||
|
// lines just stack fragments of the word, and one ellipsised line reads
|
||||||
|
// better.
|
||||||
|
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||||
|
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
(contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||||
|
}
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val 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(
|
Box(
|
||||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
|
// The source stays put as a ghost while its floating copy travels.
|
||||||
|
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||||
.background(fill, RoundedCornerShape(4.dp))
|
.background(fill, RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.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)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
.semantics { contentDescription = "$title, $timeLabel" },
|
.semantics {
|
||||||
|
contentDescription = "$title, $timeLabel"
|
||||||
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
if (showTitle) {
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
maxLines = titleMaxLines,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
|
||||||
)
|
|
||||||
if (showTime) {
|
|
||||||
Text(
|
Text(
|
||||||
text = timeLabel,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = 1,
|
maxLines = titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = eventInk(fill, alpha = 0.6f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
|
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTime) {
|
||||||
|
BlockTimeLabel(
|
||||||
|
label = timeLabel,
|
||||||
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -819,7 +963,7 @@ private fun EventBlock(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekLoading() {
|
private fun WeekLoading() {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val scale = LocalTimelineZoom.current.scale
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header skeleton
|
// Header skeleton
|
||||||
@@ -838,16 +982,26 @@ private fun WeekLoading() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||||
repeat(7) {
|
// columns don't resize the moment the real week arrives.
|
||||||
Box(
|
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||||
modifier = Modifier
|
Row(
|
||||||
.weight(1f)
|
modifier = Modifier
|
||||||
.height(totalHeight)
|
.fillMaxSize()
|
||||||
.padding(horizontal = 2.dp)
|
.padding(end = TIMELINE_CONTENT_END_INSET)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
.verticalScroll(scrollState),
|
||||||
)
|
) {
|
||||||
|
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||||
|
repeat(7) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(totalHeight)
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
|
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 kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live, scaled-down Week view for the onboarding view chooser. The week and
|
||||||
|
* today's column are real; only the events are stand-ins.
|
||||||
|
*
|
||||||
|
* The timeline is scrolled to the working day rather than to midnight — the
|
||||||
|
* live view centres on noon once it knows its own scroll range, which a preview
|
||||||
|
* that is never interacted with would otherwise never do.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun WeekViewPreview(
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
height: Dp,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
|
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||||
|
val state = remember(today, weekStart, zone) { sampleWeekState(today, weekStart, zone) }
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||||
|
// Half the scroll range is noon — the same centring the live view does.
|
||||||
|
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
|
WeekSuccess(
|
||||||
|
state = state,
|
||||||
|
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrollState = scrollState,
|
||||||
|
allDayHeight = state.allDayStripHeight(),
|
||||||
|
dragController = rememberTimelineDragController(),
|
||||||
|
onEventClick = {},
|
||||||
|
onOpenDay = {},
|
||||||
|
onCreateAt = { _, _ -> },
|
||||||
|
onDrop = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample week state, laid out through the same helpers the live view uses. */
|
||||||
|
private fun sampleWeekState(
|
||||||
|
today: LocalDate,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
|
zone: TimeZone,
|
||||||
|
): WeekUiState.Success {
|
||||||
|
val start = today.startOfWeek(weekStart)
|
||||||
|
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
|
||||||
|
val events = sampleTimelineEvents(days, today, zone)
|
||||||
|
return WeekUiState.Success(
|
||||||
|
weekStart = start,
|
||||||
|
today = today,
|
||||||
|
days = days,
|
||||||
|
allDaySpans = layoutAllDay(events.filter { it.isAllDay }, days, zone),
|
||||||
|
timedByDay = days.associateWith { layoutDay(events, it, zone) },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.widget
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the app's own wake-up for the next local midnight, so the home-screen
|
||||||
|
* widgets roll "today" over on the day boundary (#228).
|
||||||
|
*
|
||||||
|
* The widgets used to lean on `ACTION_DATE_CHANGED`, which is not an exempted
|
||||||
|
* implicit broadcast — a manifest-declared receiver has not been given it since
|
||||||
|
* Android 8, leaving only the throttled `updatePeriodMillis`.
|
||||||
|
*
|
||||||
|
* Exactly one alarm exists at a time and every firing re-arms the next, the same
|
||||||
|
* shape as [de.jeanlucmakiola.calendula.data.reminders.ReminderAlarmScheduler].
|
||||||
|
* Deliberately **inexact**: `setAndAllowWhileIdle` needs no permission and
|
||||||
|
* survives doze (plain `set` does not), a rollover a few minutes late is
|
||||||
|
* invisible on a sleeping screen, and exact alarms stay reserved for snooze.
|
||||||
|
*/
|
||||||
|
object WidgetRolloverScheduler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire just *after* midnight: an alarm delivered a few milliseconds early
|
||||||
|
* would still read the old date and re-arm for an instant later.
|
||||||
|
*/
|
||||||
|
internal val ROLLOVER_SLACK = 5.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the next rollover, or cancel a pending one when no widget is placed.
|
||||||
|
* Idempotent, so every trigger (boot, app start, widget added/removed, the
|
||||||
|
* rollover itself, a clock or timezone change) can just call it.
|
||||||
|
*/
|
||||||
|
fun sync(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val alarmManager = appContext.getSystemService<AlarmManager>() ?: return
|
||||||
|
val pendingIntent = rolloverPendingIntent(appContext)
|
||||||
|
if (!hasPlacedWidgets(appContext)) {
|
||||||
|
alarmManager.cancel(pendingIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault())
|
||||||
|
alarmManager.setAndAllowWhileIdle(
|
||||||
|
AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instant just after the next local midnight following [now] in [zone].
|
||||||
|
*
|
||||||
|
* The *actual* start of day, not 00:00, so it holds where a DST jump means
|
||||||
|
* midnight never happens (Havana) and where a date-line move skips a whole
|
||||||
|
* local date (Apia, December 2011) — the loop walks on to the next real day.
|
||||||
|
*
|
||||||
|
* The mirror case — a zone rewinding *across* midnight, so the day starts
|
||||||
|
* twice — resolves to the earlier start and runs an hour ahead of the clock.
|
||||||
|
* No live tz entry does that (Brazil dropped DST in 2019) and
|
||||||
|
* `updatePeriodMillis` covers it, so it isn't worth state to detect.
|
||||||
|
*/
|
||||||
|
fun nextRolloverAt(now: Instant, zone: TimeZone): Instant {
|
||||||
|
val date = now.toLocalDateTime(zone).date
|
||||||
|
var days = 1
|
||||||
|
while (days <= MAX_LOOKAHEAD_DAYS) {
|
||||||
|
val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK
|
||||||
|
if (candidate > now) return candidate
|
||||||
|
days++
|
||||||
|
}
|
||||||
|
// Unreachable for any zone in the tz database. Deliberately an hour and
|
||||||
|
// not the slack: a 5-second retry would just hit this branch again and
|
||||||
|
// wake the device in a loop.
|
||||||
|
return now + 1.hours
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPlacedWidgets(context: Context): Boolean {
|
||||||
|
val manager = AppWidgetManager.getInstance(context) ?: return false
|
||||||
|
return PROVIDERS.any {
|
||||||
|
manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rolloverPendingIntent(context: Context): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
ROLLOVER_REQUEST_CODE,
|
||||||
|
Intent(context, WidgetUpdateReceiver::class.java)
|
||||||
|
.setAction(WidgetUpdateReceiver.ACTION_ROLLOVER),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val PROVIDERS = listOf(
|
||||||
|
MonthWidgetReceiver::class.java,
|
||||||
|
AgendaWidgetReceiver::class.java,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Fixed: there is only ever one rollover alarm, and re-arming must replace it. */
|
||||||
|
private const val ROLLOVER_REQUEST_CODE = 0x0DA1
|
||||||
|
|
||||||
|
/** A gap of more than a couple of days does not exist in any tz database entry. */
|
||||||
|
private const val MAX_LOOKAHEAD_DAYS = 3
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import android.os.Build
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.glance.GlanceTheme
|
import androidx.glance.GlanceTheme
|
||||||
import androidx.glance.material3.ColorProviders
|
import androidx.glance.material3.ColorProviders
|
||||||
|
import androidx.glance.text.TextDecoration
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaDarkFallback
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaLightFallback
|
||||||
|
|
||||||
@@ -34,3 +35,10 @@ fun CalendulaGlanceTheme(content: @Composable () -> Unit) {
|
|||||||
}
|
}
|
||||||
GlanceTheme(colors = colors, content = content)
|
GlanceTheme(colors = colors, content = content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glance's counterpart to the app's `declinedDecoration`: a declined invitation
|
||||||
|
* reads the same on the home screen as it does inside the app (#180).
|
||||||
|
*/
|
||||||
|
fun glanceDeclinedDecoration(isDeclined: Boolean): TextDecoration =
|
||||||
|
if (isDeclined) TextDecoration.LineThrough else TextDecoration.None
|
||||||
|
|||||||
@@ -12,19 +12,40 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redraws both home-screen widgets when their data goes stale. Triggered by:
|
* Redraws both home-screen widgets when their data goes stale, and keeps the
|
||||||
|
* midnight rollover alarm armed. Triggered by:
|
||||||
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
* - `PROVIDER_CHANGED` from the calendar provider — fires on any data change,
|
||||||
* so it covers both the app's own writes and external sync.
|
* so it covers both the app's own writes and external sync.
|
||||||
* - `DATE_CHANGED` / `TIME_SET` / `TIMEZONE_CHANGED` — so "today" highlighting
|
* - [ACTION_ROLLOVER], the app's own alarm from [WidgetRolloverScheduler] —
|
||||||
* and the upcoming window roll over at midnight / on a clock change.
|
* the day boundary, so "today" highlighting and the agenda's past-event
|
||||||
|
* dimming move on (#228).
|
||||||
|
* - `TIME_SET` / `TIMEZONE_CHANGED` — the day boundary moved, so redraw *and*
|
||||||
|
* re-arm.
|
||||||
|
* - `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED` — both wipe pending alarms; the
|
||||||
|
* latter is also what arms installs upgrading into the fix.
|
||||||
*
|
*
|
||||||
* Both widgets also carry an `updatePeriodMillis` backstop in their provider
|
* `DATE_CHANGED` is a free extra in the filter that nothing depends on — see
|
||||||
* XML, and the month widget's refresh button forces an immediate redraw.
|
* [WidgetRolloverScheduler]. The backstops are `updatePeriodMillis` in the
|
||||||
|
* provider XML and the month widget's refresh button.
|
||||||
|
*
|
||||||
|
* Exported for the system broadcasts; an extra redraw from another app is
|
||||||
|
* harmless.
|
||||||
*/
|
*/
|
||||||
class WidgetUpdateReceiver : BroadcastReceiver() {
|
class WidgetUpdateReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val pending = goAsync()
|
// Exported, so anything can reach it with an explicit intent. Nothing
|
||||||
|
// here crosses a trust boundary, but narrowing to the actions we asked
|
||||||
|
// for keeps a stray broadcast from costing two wide provider reads.
|
||||||
|
if (intent.action !in HANDLED_ACTIONS) return
|
||||||
val appContext = context.applicationContext
|
val appContext = context.applicationContext
|
||||||
|
// Re-arm first, so the next day boundary is covered whatever the redraw
|
||||||
|
// does. Every handled action either dropped, consumed or invalidated it.
|
||||||
|
WidgetRolloverScheduler.sync(appContext)
|
||||||
|
// The host sends APPWIDGET_UPDATE after both of these anyway, so
|
||||||
|
// redrawing here would only repeat the work in a cold process, at the
|
||||||
|
// moment the device is most contended.
|
||||||
|
if (intent.action in REARM_ONLY_ACTIONS) return
|
||||||
|
val pending = goAsync()
|
||||||
// Calendar data may have changed (sync / our own write) — drop the cached
|
// Calendar data may have changed (sync / our own write) — drop the cached
|
||||||
// month window so the widgets reload fresh. Month paging does NOT call
|
// month window so the widgets reload fresh. Month paging does NOT call
|
||||||
// this, so arrow taps stay instant.
|
// this, so arrow taps stay instant.
|
||||||
@@ -38,4 +59,23 @@ class WidgetUpdateReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The app's own midnight wake-up; see [WidgetRolloverScheduler]. */
|
||||||
|
const val ACTION_ROLLOVER = "de.jeanlucmakiola.calendula.widget.ROLLOVER"
|
||||||
|
|
||||||
|
/** Both wipe pending alarms, and the host redraws the widgets itself after them. */
|
||||||
|
private val REARM_ONLY_ACTIONS = setOf(
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal val HANDLED_ACTIONS = REARM_ONLY_ACTIONS + setOf(
|
||||||
|
ACTION_ROLLOVER,
|
||||||
|
Intent.ACTION_PROVIDER_CHANGED,
|
||||||
|
Intent.ACTION_DATE_CHANGED,
|
||||||
|
Intent.ACTION_TIME_CHANGED,
|
||||||
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -24,7 +25,6 @@ import androidx.glance.appwidget.cornerRadius
|
|||||||
import androidx.glance.appwidget.lazy.LazyColumn
|
import androidx.glance.appwidget.lazy.LazyColumn
|
||||||
import androidx.glance.appwidget.lazy.items
|
import androidx.glance.appwidget.lazy.items
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.appwidget.updateAll
|
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
@@ -58,6 +58,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||||
|
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.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
@@ -73,11 +74,6 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
|
||||||
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
|
||||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
|
||||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||||
* The range is read reactively in the composition ([currentState]) so a settings
|
* The range is read reactively in the composition ([currentState]) so a settings
|
||||||
@@ -112,6 +108,11 @@ internal val AGENDA_SHOW_TODAY_STATE_KEY = booleanPreferencesKey("agenda_show_to
|
|||||||
*/
|
*/
|
||||||
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
internal val AGENDA_SIZE_KEY = stringPreferencesKey("widget_size")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Upcoming" agenda widget — a continuously scrolling list of the next ~30 days
|
||||||
|
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||||
|
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||||
|
*/
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
@@ -134,17 +135,23 @@ class AgendaWidget : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-reads the calendar and redraws the widget (header refresh button). */
|
/**
|
||||||
|
* Redraws the widget (header refresh button). Targets the tapped widget's own id
|
||||||
|
* rather than `updateAll`, whose provider-name lookup is empty in a process a tap
|
||||||
|
* woke from cold — see `ShiftMonthAction` (#18). A cold process re-reads the
|
||||||
|
* calendar in the `provideGlance` preamble; a live session only recomposes from
|
||||||
|
* the snapshot it already has (see [AGENDA_RANGE_KEY]).
|
||||||
|
*/
|
||||||
class RefreshAgendaAction : ActionCallback {
|
class RefreshAgendaAction : ActionCallback {
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
AgendaWidget().updateAll(context.applicationContext)
|
AgendaWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
* Upper bound on rows handed to the [LazyColumn], so the serialized RemoteViews
|
||||||
* stays well inside the binder transaction limit regardless of range and calendar
|
* stays well inside the binder transaction limit regardless of range and calendar
|
||||||
* size (see the [SizeMode.Exact] note above). Far more than fits on screen — a
|
* size (see the [SizeMode.Single] note above). Far more than fits on screen — a
|
||||||
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
* user scrolling a home-screen widget past a hundred rows is not a case worth
|
||||||
* risking a failed update for.
|
* risking a failed update for.
|
||||||
*/
|
*/
|
||||||
@@ -382,7 +389,7 @@ private fun EventRow(
|
|||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||||
val stripeColor = eventFill(event.color, dark, soften).let {
|
val stripeColor = eventAccent(event.color, dark, soften).let {
|
||||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||||
}
|
}
|
||||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||||
@@ -415,7 +422,11 @@ private fun EventRow(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = titleColor, fontSize = metrics.eventTitle),
|
style = TextStyle(
|
||||||
|
color = titleColor,
|
||||||
|
fontSize = metrics.eventTitle,
|
||||||
|
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, day, is24Hour),
|
text = eventTimeSummary(context, event, day, is24Hour),
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.agenda
|
package de.jeanlucmakiola.calendula.widget.agenda
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Context
|
||||||
import androidx.glance.appwidget.GlanceAppWidget
|
import androidx.glance.appwidget.GlanceAppWidget
|
||||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||||
|
import de.jeanlucmakiola.calendula.widget.WidgetRolloverScheduler
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
* Host-facing receiver for the agenda widget. Declared in the manifest with the
|
||||||
@@ -10,4 +13,32 @@ import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
|||||||
*/
|
*/
|
||||||
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
class AgendaWidgetReceiver : GlanceAppWidgetReceiver() {
|
||||||
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
override val glanceAppWidget: GlanceAppWidget = AgendaWidget()
|
||||||
|
|
||||||
|
/** First agenda widget placed — start rolling "today" over at midnight (#228). */
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
super.onEnabled(context)
|
||||||
|
WidgetRolloverScheduler.sync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last agenda widget removed. [WidgetRolloverScheduler.sync] cancels only if
|
||||||
|
* no month widget is left either.
|
||||||
|
*/
|
||||||
|
override fun onDisabled(context: Context) {
|
||||||
|
super.onDisabled(context)
|
||||||
|
WidgetRolloverScheduler.sync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-heal on the system's own `updatePeriodMillis` wake-up — see
|
||||||
|
* [de.jeanlucmakiola.calendula.widget.month.MonthWidgetReceiver.onUpdate].
|
||||||
|
*/
|
||||||
|
override fun onUpdate(
|
||||||
|
context: Context,
|
||||||
|
appWidgetManager: AppWidgetManager,
|
||||||
|
appWidgetIds: IntArray,
|
||||||
|
) {
|
||||||
|
super.onUpdate(context, appWidgetManager, appWidgetIds)
|
||||||
|
WidgetRolloverScheduler.sync(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.widget.month
|
package de.jeanlucmakiola.calendula.widget.month
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -25,7 +26,6 @@ import androidx.glance.appwidget.action.actionStartActivity
|
|||||||
import androidx.glance.appwidget.cornerRadius
|
import androidx.glance.appwidget.cornerRadius
|
||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
import androidx.glance.appwidget.updateAll
|
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
import androidx.glance.currentState
|
import androidx.glance.currentState
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
@@ -33,6 +33,7 @@ import androidx.glance.layout.Box
|
|||||||
import androidx.glance.layout.Column
|
import androidx.glance.layout.Column
|
||||||
import androidx.glance.layout.Row
|
import androidx.glance.layout.Row
|
||||||
import androidx.glance.layout.Spacer
|
import androidx.glance.layout.Spacer
|
||||||
|
import androidx.glance.layout.fillMaxHeight
|
||||||
import androidx.glance.layout.fillMaxSize
|
import androidx.glance.layout.fillMaxSize
|
||||||
import androidx.glance.layout.fillMaxWidth
|
import androidx.glance.layout.fillMaxWidth
|
||||||
import androidx.glance.layout.height
|
import androidx.glance.layout.height
|
||||||
@@ -97,10 +98,27 @@ private fun yearMonthOf(index: Int): YearMonth =
|
|||||||
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
* inter-cell seam, with rounded end caps. The displayed month lives in Glance
|
||||||
* state and is read reactively in the composition ([currentState]) so the arrows
|
* state and is read reactively in the composition ([currentState]) so the arrows
|
||||||
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
* move it via plain recomposition, not a (here-unreliable) widget session reload.
|
||||||
|
*
|
||||||
|
* Everything here is written to keep the published `RemoteViews` small. A widget
|
||||||
|
* update reaches the launcher as a *oneway* binder call, and the host's async
|
||||||
|
* buffer is 1 MB for all of it; overrun throws `TransactionTooLargeException` and
|
||||||
|
* the system drops the whole host, freezing every widget on the home screen until
|
||||||
|
* the launcher rebinds. [SizeMode.Exact] serialises the grid once per size the
|
||||||
|
* host reports, so the ceiling arrives at half the view count you would expect: a
|
||||||
|
* grid that emitted a view per cell blew past it after three taps (#214). Every
|
||||||
|
* row here emits only what it fills.
|
||||||
*/
|
*/
|
||||||
class MonthWidget : GlanceAppWidget() {
|
class MonthWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact rather than [SizeMode.Single] or [SizeMode.Responsive], both of which
|
||||||
|
* return early from `GlanceAppWidget.resize` — under those the grid is never
|
||||||
|
* recomposed for a new size and a resized widget clips instead of reflowing.
|
||||||
|
* Exact costs one serialised copy per host size, which is affordable now that
|
||||||
|
* a rendering is ~190 views rather than ~740.
|
||||||
|
*/
|
||||||
override val sizeMode = SizeMode.Exact
|
override val sizeMode = SizeMode.Exact
|
||||||
|
|
||||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
@@ -118,15 +136,35 @@ class MonthWidget : GlanceAppWidget() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step the displayed month by the `delta` action parameter (±1). */
|
/**
|
||||||
|
* Step the displayed month by the `delta` action parameter (±1).
|
||||||
|
*
|
||||||
|
* Redrawn through [GlanceAppWidget.update] with the id the callback was handed,
|
||||||
|
* never `updateAll`. `updateAll` resolves its targets through the provider-name
|
||||||
|
* -> app-widget-id map Glance persists for the *receiver*, and in a process that
|
||||||
|
* has done nothing else yet — the one a tap wakes after a reboot — that lookup
|
||||||
|
* comes back empty and the redraw is dropped: the state write lands, nothing
|
||||||
|
* recomposes, and the arrows read as dead until something else opens the app and
|
||||||
|
* starts a session (#18). The tapped widget's own id needs no lookup.
|
||||||
|
*/
|
||||||
class ShiftMonthAction : ActionCallback {
|
class ShiftMonthAction : ActionCallback {
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
val delta = parameters[deltaKey] ?: 0
|
val delta = parameters[deltaKey] ?: 0
|
||||||
updateAppWidgetState(context, glanceId) { prefs ->
|
updateAppWidgetState(context, glanceId) { prefs ->
|
||||||
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
val cur = prefs[MONTH_INDEX_KEY] ?: currentMonthIndex(systemZone())
|
||||||
prefs[MONTH_INDEX_KEY] = cur + delta
|
val next = cur + delta
|
||||||
|
// Landing back on the current month clears the key, so the widget
|
||||||
|
// goes back to *following* the date rather than being pinned to
|
||||||
|
// whichever month was current at the tap. Paging out and back is the
|
||||||
|
// workaround #228's reporter used, and pinning it there would have
|
||||||
|
// stuck them on that month once it stopped being the current one.
|
||||||
|
if (next == currentMonthIndex(systemZone())) {
|
||||||
|
prefs.remove(MONTH_INDEX_KEY)
|
||||||
|
} else {
|
||||||
|
prefs[MONTH_INDEX_KEY] = next
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MonthWidget().updateAll(context.applicationContext)
|
MonthWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -134,11 +172,11 @@ class ShiftMonthAction : ActionCallback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Jump the displayed month back to the current month. */
|
/** Jump the displayed month back to the current month. See [ShiftMonthAction]. */
|
||||||
class ResetMonthAction : ActionCallback {
|
class ResetMonthAction : ActionCallback {
|
||||||
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
|
||||||
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
updateAppWidgetState(context, glanceId) { prefs -> prefs.remove(MONTH_INDEX_KEY) }
|
||||||
MonthWidget().updateAll(context.applicationContext)
|
MonthWidget().update(context.applicationContext, glanceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,29 +324,75 @@ private fun WeekRow(
|
|||||||
colW: Dp,
|
colW: Dp,
|
||||||
modifier: GlanceModifier,
|
modifier: GlanceModifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
val context = LocalContext.current
|
||||||
// Day numbers.
|
val hidden = week.hiddenPerDay()
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
|
// Day-open targets, one full-height strip per column, sitting *under* the
|
||||||
|
// content: a tap anywhere in a day still opens it, as in the app, without
|
||||||
|
// every cell in the column carrying a click target of its own. The content
|
||||||
|
// above is not clickable, so touches fall through to this layer; only an
|
||||||
|
// event bar opts out to open its own detail.
|
||||||
|
Row(modifier = GlanceModifier.fillMaxSize()) {
|
||||||
week.days.forEach { date ->
|
week.days.forEach { date ->
|
||||||
DayNumber(
|
Box(
|
||||||
date = date,
|
modifier = GlanceModifier
|
||||||
isToday = date == today,
|
.width(colW)
|
||||||
inMonth = date.month == currentMonth,
|
.fillMaxHeight()
|
||||||
colW = colW,
|
.clickable(openDayAction(context, date)),
|
||||||
)
|
) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(GlanceModifier.height(2.dp))
|
Column(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
// One lane row per event row. A multi-day span is a single Box spanning
|
// Day numbers. Fixed height so every week's number row lines up whether
|
||||||
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
// or not it holds today's larger pill.
|
||||||
repeat(MAX_LANES) { lane ->
|
Row(
|
||||||
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
modifier = GlanceModifier.fillMaxWidth().height(DAY_NUMBER_HEIGHT),
|
||||||
Spacer(GlanceModifier.height(1.dp))
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
week.days.forEach { date ->
|
||||||
|
DayNumber(
|
||||||
|
date = date,
|
||||||
|
isToday = date == today,
|
||||||
|
inMonth = date.month == currentMonth,
|
||||||
|
colW = colW,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(GlanceModifier.height(2.dp))
|
||||||
|
// One lane row per event row. A multi-day span is a single Box spanning
|
||||||
|
// its columns (colW * n) so it's connected with no seam and rounded ends.
|
||||||
|
// Lanes past the last one this week fills emit nothing at all.
|
||||||
|
repeat(week.usedLanes()) { lane ->
|
||||||
|
LaneRow(week = week, lane = lane, dark = dark, soften = soften, colW = colW)
|
||||||
|
Spacer(GlanceModifier.height(1.dp))
|
||||||
|
}
|
||||||
|
if (hidden.any { it > 0 }) OverflowRow(hidden = hidden, colW = colW)
|
||||||
}
|
}
|
||||||
OverflowRow(week = week, colW = colW)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many lanes this week actually fills, counting from the top. The grid draws
|
||||||
|
* that many rows instead of a fixed [MAX_LANES]; an empty lane costs three views
|
||||||
|
* per day and most weeks leave the last two empty.
|
||||||
|
*/
|
||||||
|
private fun MonthWeek.usedLanes(): Int {
|
||||||
|
for (lane in MAX_LANES - 1 downTo 0) {
|
||||||
|
val filled = spans.any { it.lane == lane } ||
|
||||||
|
days.indices.any { col -> timedEventAt(this, lane, col, days[col]) != null }
|
||||||
|
if (filled) return lane + 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Events per day that no lane had room for — the "+N" counts, 0 where all fit. */
|
||||||
|
private fun MonthWeek.hiddenPerDay(): List<Int> = days.mapIndexed { col, date ->
|
||||||
|
val shownSpans = spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
|
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||||
|
val timedShown = minOf(freeSlots, timedByDay[date].orEmpty().size)
|
||||||
|
((countByDay[date] ?: 0) - shownSpans - timedShown).coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||||
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||||
@@ -318,64 +402,93 @@ private fun WeekRow(
|
|||||||
private fun openDayAction(context: Context, date: LocalDate) =
|
private fun openDayAction(context: Context, date: LocalDate) =
|
||||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day's number. Every day but today is a bare centred [Text] carrying the column
|
||||||
|
* width itself — a wrapping Box costs three views, and there are 42 of these.
|
||||||
|
* Today keeps its filled circle, which does need the Box.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
val context = LocalContext.current
|
val style = TextStyle(
|
||||||
Box(
|
color = when {
|
||||||
modifier = GlanceModifier
|
isToday -> GlanceTheme.colors.onPrimary
|
||||||
.width(colW)
|
inMonth -> GlanceTheme.colors.onSurface
|
||||||
.height(DAY_NUMBER_HEIGHT)
|
else -> GlanceTheme.colors.onSurfaceVariant
|
||||||
.clickable(openDayAction(context, date)),
|
},
|
||||||
contentAlignment = Alignment.Center,
|
fontSize = 11.sp,
|
||||||
) {
|
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (!isToday) {
|
||||||
|
Text(text = date.day.toString(), style = style, modifier = GlanceModifier.width(colW))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Box(modifier = GlanceModifier.width(colW), contentAlignment = Alignment.Center) {
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.size(DAY_NUMBER_HEIGHT)
|
.size(DAY_NUMBER_HEIGHT)
|
||||||
.then(if (isToday) GlanceModifier.cornerRadius(DAY_NUMBER_HEIGHT / 2).background(GlanceTheme.colors.primary) else GlanceModifier),
|
.cornerRadius(DAY_NUMBER_HEIGHT / 2)
|
||||||
|
.background(GlanceTheme.colors.primary),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(text = date.day.toString(), style = style)
|
||||||
text = date.day.toString(),
|
|
||||||
style = TextStyle(
|
|
||||||
color = when {
|
|
||||||
isToday -> GlanceTheme.colors.onPrimary
|
|
||||||
inMonth -> GlanceTheme.colors.onSurface
|
|
||||||
else -> GlanceTheme.colors.onSurfaceVariant
|
|
||||||
},
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A bar occupying [cols] columns, or — with a null event — that many blank ones. */
|
||||||
|
private data class LaneCell(val event: EventInstance?, val cols: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lane split into bars and the gaps between them, with consecutive blank columns
|
||||||
|
* merged into one gap: the week's day-open taps come from the strip underneath, so
|
||||||
|
* a gap needs no per-column view and a mostly-empty lane collapses to a spacer.
|
||||||
|
*/
|
||||||
|
private fun MonthWeek.laneCells(lane: Int): List<LaneCell> {
|
||||||
|
val cells = mutableListOf<LaneCell>()
|
||||||
|
var gap = 0
|
||||||
|
var col = 0
|
||||||
|
fun closeGap() {
|
||||||
|
if (gap > 0) cells += LaneCell(null, gap)
|
||||||
|
gap = 0
|
||||||
|
}
|
||||||
|
while (col < 7) {
|
||||||
|
val span = spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
||||||
|
val timed = if (span == null) timedEventAt(this, lane, col, days[col]) else null
|
||||||
|
when {
|
||||||
|
span != null -> {
|
||||||
|
closeGap()
|
||||||
|
cells += LaneCell(span.event, span.endCol - col + 1)
|
||||||
|
col = span.endCol + 1
|
||||||
|
}
|
||||||
|
timed != null -> {
|
||||||
|
closeGap()
|
||||||
|
cells += LaneCell(timed, 1)
|
||||||
|
col += 1
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
gap += 1
|
||||||
|
col += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeGap()
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, soften: Boolean, colW: Dp) {
|
||||||
val context = LocalContext.current
|
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
var col = 0
|
week.laneCells(lane).forEach { cell ->
|
||||||
while (col < 7) {
|
if (cell.event == null) {
|
||||||
val span = week.spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
|
Spacer(GlanceModifier.width(colW * cell.cols).height(LANE_HEIGHT))
|
||||||
if (span != null) {
|
|
||||||
val cols = span.endCol - col + 1
|
|
||||||
SpanBar(event = span.event, dark = dark, soften = soften, width = colW * cols)
|
|
||||||
col = span.endCol + 1
|
|
||||||
} else {
|
} else {
|
||||||
val timed = timedEventAt(week, lane, col, week.days[col])
|
SpanBar(
|
||||||
if (timed != null) {
|
event = cell.event,
|
||||||
SpanBar(event = timed, dark = dark, soften = soften, width = colW)
|
dark = dark,
|
||||||
} else {
|
soften = soften,
|
||||||
// Empty lane cell: a tap opens that day, so blank space in a
|
width = colW * cell.cols,
|
||||||
// day column is a day-open target just like the number is.
|
)
|
||||||
Box(
|
|
||||||
GlanceModifier
|
|
||||||
.width(colW)
|
|
||||||
.height(LANE_HEIGHT)
|
|
||||||
.clickable(openDayAction(context, week.days[col])),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
col += 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,41 +527,39 @@ private fun SpanBar(event: EventInstance, dark: Boolean, soften: Boolean, width:
|
|||||||
Text(
|
Text(
|
||||||
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
text = event.title.ifBlank { context.getString(R.string.event_untitled) },
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = ColorProvider(eventInk(fill)), fontSize = 9.sp),
|
style = TextStyle(
|
||||||
|
color = ColorProvider(eventInk(fill)),
|
||||||
|
fontSize = 9.sp,
|
||||||
|
textDecoration = glanceDeclinedDecoration(event.isDeclined),
|
||||||
|
),
|
||||||
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
modifier = GlanceModifier.padding(horizontal = 3.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The "+N" row, drawn only for weeks that hide something, gaps merged as in a lane. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
private fun OverflowRow(hidden: List<Int>, colW: Dp) {
|
||||||
val context = LocalContext.current
|
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
week.days.forEachIndexed { col, date ->
|
var gap = 0
|
||||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
hidden.forEach { count ->
|
||||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
if (count == 0) {
|
||||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
gap += 1
|
||||||
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
return@forEach
|
||||||
// The overflow row is part of the day column too: tapping it (whether
|
|
||||||
// it shows "+N" or is blank) opens that day, same as the app.
|
|
||||||
Box(
|
|
||||||
modifier = GlanceModifier
|
|
||||||
.width(colW)
|
|
||||||
.height(LANE_HEIGHT)
|
|
||||||
.clickable(openDayAction(context, date)),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
if (hidden > 0) {
|
|
||||||
Text(
|
|
||||||
text = "+$hidden",
|
|
||||||
maxLines = 1,
|
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
|
||||||
modifier = GlanceModifier.padding(start = 3.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (gap > 0) {
|
||||||
|
Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||||
|
gap = 0
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "+$count",
|
||||||
|
maxLines = 1,
|
||||||
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 9.sp),
|
||||||
|
modifier = GlanceModifier.width(colW).padding(start = 3.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
if (gap > 0) Spacer(GlanceModifier.width(colW * gap).height(LANE_HEIGHT))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user